savegame ex.: revamp the way we (de)serialize JSON

JSON, unlike, say, QDataStream, allows building up objects independent
of some central object, and combining them into a QJsonDocument
later. This suggests returning QJsonObjects from a toJson() const
method instead of having the caller supply a QJsonObject. Doing it
this way enables transparent move semantics to kick in, too.

For deserialization, use a fromJson() named constructor for value-like
classes (where identity doesn't matter, only equality). Keep using
read(), too, and add a note to explain when to use which form.

Also, avoid the triple lookup from

   if (json.contains("key") && json["key"].isSoughtType())
      mFoo = json["key"].toSoughtType();

by using C++17 if-with-initializer and showing the trick with
Undefined never being of isSoughtType():

   if (const QJsonValue v = json["key"]; v.isSoughtType())
      mFoo = v.toSoughtType();

Adjust the discussion to match the new code, up the copyright years
and rename some qdoc snippet markers from nondescript [0]/[1] to
[toJson]/[fromJson].

Task-number: QTBUG-108857
Change-Id: Icaa14acc7464fef00a59534679d710252e921383
Reviewed-by: Qt CI Bot <qt_ci_bot@qt-project.org>
Reviewed-by: Mårten Nordheim <marten.nordheim@qt.io>
(cherry picked from commit 5a3ac484dbcc64c8ee7a57854fcdde6b4b067aaa)
Reviewed-by: Qt Cherry-pick Bot <cherrypick_bot@qt-project.org>
This commit is contained in:
Marc Mutz 2023-02-07 13:52:50 +01:00 committed by Qt Cherry-pick Bot
parent 6382fdca60
commit 7eafdf4c81
7 changed files with 166 additions and 113 deletions

View File

@ -48,28 +48,34 @@ void Character::setClassType(Character::ClassType classType)
mClassType = classType; mClassType = classType;
} }
//! [0] //! [fromJson]
void Character::read(const QJsonObject &json) Character Character::fromJson(const QJsonObject &json)
{ {
if (json.contains("name") && json["name"].isString()) Character result;
mName = json["name"].toString();
if (json.contains("level") && json["level"].isDouble()) if (const QJsonValue v = json["name"]; v.isString())
mLevel = json["level"].toInt(); result.mName = v.toString();
if (json.contains("classType") && json["classType"].isDouble()) if (const QJsonValue v = json["level"]; v.isDouble())
mClassType = ClassType(json["classType"].toInt()); result.mLevel = v.toInt();
if (const QJsonValue v = json["classType"]; v.isDouble())
result.mClassType = ClassType(v.toInt());
return result;
} }
//! [0] //! [fromJson]
//! [1] //! [toJson]
void Character::write(QJsonObject &json) const QJsonObject Character::toJson() const
{ {
QJsonObject json;
json["name"] = mName; json["name"] = mName;
json["level"] = mLevel; json["level"] = mLevel;
json["classType"] = mClassType; json["classType"] = mClassType;
return json;
} }
//! [1] //! [toJson]
void Character::print(int indentation) const void Character::print(int indentation) const
{ {

View File

@ -31,8 +31,8 @@ public:
ClassType classType() const; ClassType classType() const;
void setClassType(ClassType classType); void setClassType(ClassType classType);
void read(const QJsonObject &json); static Character fromJson(const QJsonObject &json);
void write(QJsonObject &json) const; QJsonObject toJson() const;
void print(int indentation = 0) const; void print(int indentation = 0) const;
private: private:

View File

@ -24,45 +24,81 @@
The Character class represents a non-player character (NPC) in our game, and The Character class represents a non-player character (NPC) in our game, and
stores the player's name, level, and class type. stores the player's name, level, and class type.
It provides read() and write() functions to serialise its member variables. It provides static fromJson() and non-static toJson() functions to
serialise itself.
\note This pattern (fromJson()/toJson()) works because QJsonObjects can be
constructed independent of an owning QJsonDocument, and because the data
types being (de)serialized here are value types, so can be copied. When
serializing to another format — for example XML or QDataStream, which require passing
a document-like object — or when the object identity is important (QObject
subclasses, for example), other patterns may be more suitable. See the
\l{xml/dombookmarks} and \l{xml/streambookmarks} examples for XML, and the
implementation of \l QListWidgetItem::read() and \l QListWidgetItem::write()
for idiomatic QDataStream serialization.
\snippet serialization/savegame/character.h 0 \snippet serialization/savegame/character.h 0
Of particular interest to us are the read and write function Of particular interest to us are the fromJson() and toJson() function
implementations: implementations:
\snippet serialization/savegame/character.cpp 0 \snippet serialization/savegame/character.cpp fromJson
In the read() function, we assign Character's members values from the In the fromJson() function, we construct a local \c result Character object
QJsonObject argument. You can use either \l QJsonObject::operator[]() or and assign \c{result}'s members values from the QJsonObject argument. You
QJsonObject::value() to access values within the JSON object; both are can use either \l QJsonObject::operator[]() or QJsonObject::value() to
const functions and return QJsonValue::Undefined if the key is invalid. We access values within the JSON object; both are const functions and return
check if the keys are valid before attempting to read them with QJsonValue::Undefined if the key is invalid. In particular, the \c{is...}
QJsonObject::contains(). functions (for example \l QJsonValue::isString(), \l
QJsonValue::isDouble()) return \c false for QJsonValue::Undefined, so we
can check for existence as well as the correct type in a single lookup.
\snippet serialization/savegame/character.cpp 1 If a value does not exist in the JSON object, or has the wrong type, we
don't write to the corresponding \c result member, either, thereby
preserving any values the default constructor may have set. This means
default values are centrally defined in one location (the default
constructor) and need not be repeated in serialisation code
(\l{https://en.wikipedia.org/wiki/Don%27t_repeat_yourself}{DRY}).
In the write() function, we do the reverse of the read() function; assign Observe the use of
values from the Character object to the JSON object. As with accessing \l{https://en.cppreference.com/w/cpp/language/if#If_statements_with_initializer}
values, there are two ways to set values on a QJsonObject: {C++17 if-with-initializer} to separate scoping and checking of the variable \c v.
\l QJsonObject::operator[]() and QJsonObject::insert(). Both will override This means we can keep the variable name short, because its scope is limited.
any existing value at the given key.
Next up is the Level class: Compare that to the naïve approach using \c QJsonObject::contains():
\badcode
if (json.contains("name") && json["name"].isString())
result.mName = json["name"].toString();
\endcode
which, beside being less readable, requires a total of three lookups (no,
the compiler will \e not optimize these into one), so is three times
slower and repeats \c{"name"} three times (violating the DRY principle).
\snippet serialization/savegame/character.cpp toJson
In the toJson() function, we do the reverse of the fromJson() function;
assign values from the Character object to a new JSON object we then
return. As with accessing values, there are two ways to set values on a
QJsonObject: \l QJsonObject::operator[]() and \l QJsonObject::insert().
Both will override any existing value at the given key.
\section1 The Level Class
\snippet serialization/savegame/level.h 0 \snippet serialization/savegame/level.h 0
We want to have several levels in our game, each with several NPCs, so we We want the levels in our game to each each have several NPCs, so we keep a QList
keep a QList of Character objects. We also provide the familiar read() and of Character objects. We also provide the familiar fromJson() and toJson()
write() functions. functions.
\snippet serialization/savegame/level.cpp 0 \snippet serialization/savegame/level.cpp fromJson
Containers can be written and read to and from JSON using QJsonArray. In our Containers can be written to and read from JSON using QJsonArray. In our
case, we construct a QJsonArray from the value associated with the key case, we construct a QJsonArray from the value associated with the key
\c "npcs". Then, for each QJsonValue element in the array, we call \c "npcs". Then, for each QJsonValue element in the array, we call
toObject() to get the Character's JSON object. The Character object can then toObject() to get the Character's JSON object. Character::fromJson() can
read their JSON and be appended to our NPC array. then turn that QJSonObject into a Character object to append to our NPC array.
\note \l{Container Classes}{Associate containers} can be written by storing \note \l{Container Classes}{Associate containers} can be written by storing
the key in each value object (if it's not already). With this approach, the the key in each value object (if it's not already). With this approach, the
@ -70,11 +106,13 @@
element is used as the key to construct the container when reading it back element is used as the key to construct the container when reading it back
in. in.
\snippet serialization/savegame/level.cpp 1 \snippet serialization/savegame/level.cpp toJson
Again, the write() function is similar to the read() function, except Again, the toJson() function is similar to the fromJson() function, except
reversed. reversed.
\section1 The Game Class
Having established the Character and Level classes, we can move on to Having established the Character and Level classes, we can move on to
the Game class: the Game class:
@ -86,26 +124,43 @@
Next, we provide accessors for the player and levels. We then expose three Next, we provide accessors for the player and levels. We then expose three
functions: newGame(), saveGame() and loadGame(). functions: newGame(), saveGame() and loadGame().
The read() and write() functions are used by saveGame() and loadGame(). The read() and toJson() functions are used by saveGame() and loadGame().
\snippet serialization/savegame/game.cpp 0 \div{class="admonition note"}\b{Note:}
Despite \c Game being a value class, we assume that the author wants a game to have
identity, much like your main window would have. We therefore don't use a
static fromJson() function, which would create a new object, but a read()
function we can call on existing objects. There's a 1:1 correspondence
between read() and fromJson(), in that one can be implemented in terms of
the other:
\code
void read(const QJsonObject &json) { *this = fromJson(json); }
static Game fromObject(const QJsonObject &json) { Game g; g.read(json); return g; }
\endcode
We just use what's more convenient for callers of the functions.
\enddiv
\snippet serialization/savegame/game.cpp newGame
To setup a new game, we create the player and populate the levels and their To setup a new game, we create the player and populate the levels and their
NPCs. NPCs.
\snippet serialization/savegame/game.cpp 1 \snippet serialization/savegame/game.cpp read
The first thing we do in the read() function is tell the player to read The read() function starts by replacing the player with the
itself. We then clear the level array so that calling loadGame() on the one read from JSON. We then clear() the level array so that calling
same Game object twice doesn't result in old levels hanging around. loadGame() on the same Game object twice doesn't result in old levels
hanging around.
We then populate the level array by reading each Level from a QJsonArray. We then populate the level array by reading each Level from a QJsonArray.
\snippet serialization/savegame/game.cpp 2 \snippet serialization/savegame/game.cpp toJson
We write the game to JSON similarly to how we write Level. Writing the game to JSON is similar to writing a level.
\snippet serialization/savegame/game.cpp 3 \snippet serialization/savegame/game.cpp loadGame
When loading a saved game in loadGame(), the first thing we do is open the When loading a saved game in loadGame(), the first thing we do is open the
save file based on which format it was saved to; \c "save.json" for JSON, save file based on which format it was saved to; \c "save.json" for JSON,
@ -119,14 +174,16 @@
After constructing the QJsonDocument, we instruct the Game object to read After constructing the QJsonDocument, we instruct the Game object to read
itself and then return \c true to indicate success. itself and then return \c true to indicate success.
\snippet serialization/savegame/game.cpp 4 \snippet serialization/savegame/game.cpp saveGame
Not surprisingly, saveGame() looks very much like loadGame(). We determine Not surprisingly, saveGame() looks very much like loadGame(). We determine
the file extension based on the format, print a warning and return \c false the file extension based on the format, print a warning and return \c false
if the opening of the file fails. We then write the Game object to a if the opening of the file fails. We then write the Game object to a
QJsonDocument, and call either QJsonDocument::toJson() or to QJsonObject. To save the game in the format that was specified, we
QJsonDocument::toBinaryData() to save the game, depending on which format convert the JSON object into either a QJsonDocument for a subsequent
was specified. QJsonDocument::toJson() call, or a QCborValue for QCborValue::toCbor().
\section1 Tying It All Together
We are now ready to enter main(): We are now ready to enter main():

View File

@ -21,7 +21,7 @@ QList<Level> Game::levels() const
return mLevels; return mLevels;
} }
//! [0] //! [newGame]
void Game::newGame() void Game::newGame()
{ {
mPlayer = Character(); mPlayer = Character();
@ -59,9 +59,9 @@ void Game::newGame()
dungeon.setNpcs(dungeonNpcs); dungeon.setNpcs(dungeonNpcs);
mLevels.append(dungeon); mLevels.append(dungeon);
} }
//! [0] //! [newGame]
//! [3] //! [loadGame]
bool Game::loadGame(Game::SaveFormat saveFormat) bool Game::loadGame(Game::SaveFormat saveFormat)
{ {
QFile loadFile(saveFormat == Json QFile loadFile(saveFormat == Json
@ -87,9 +87,9 @@ bool Game::loadGame(Game::SaveFormat saveFormat)
<< (saveFormat != Json ? "CBOR" : "JSON") << "...\n"; << (saveFormat != Json ? "CBOR" : "JSON") << "...\n";
return true; return true;
} }
//! [3] //! [loadGame]
//! [4] //! [saveGame]
bool Game::saveGame(Game::SaveFormat saveFormat) const bool Game::saveGame(Game::SaveFormat saveFormat) const
{ {
QFile saveFile(saveFormat == Json QFile saveFile(saveFormat == Json
@ -101,52 +101,44 @@ bool Game::saveGame(Game::SaveFormat saveFormat) const
return false; return false;
} }
QJsonObject gameObject; QJsonObject gameObject = toJson();
write(gameObject);
saveFile.write(saveFormat == Json saveFile.write(saveFormat == Json
? QJsonDocument(gameObject).toJson() ? QJsonDocument(gameObject).toJson()
: QCborValue::fromJsonValue(gameObject).toCbor()); : QCborValue::fromJsonValue(gameObject).toCbor());
return true; return true;
} }
//! [4] //! [saveGame]
//! [1] //! [read]
void Game::read(const QJsonObject &json) void Game::read(const QJsonObject &json)
{ {
if (json.contains("player") && json["player"].isObject()) if (const QJsonValue v = json["player"]; v.isObject())
mPlayer.read(json["player"].toObject()); mPlayer = Character::fromJson(v.toObject());
if (json.contains("levels") && json["levels"].isArray()) { if (const QJsonValue v = json["levels"]; v.isArray()) {
QJsonArray levelArray = json["levels"].toArray(); const QJsonArray levels = v.toArray();
mLevels.clear(); mLevels.clear();
mLevels.reserve(levelArray.size()); mLevels.reserve(levels.size());
for (const QJsonValue &v : levelArray) { for (const QJsonValue &level : levels)
QJsonObject levelObject = v.toObject(); mLevels.append(Level::fromJson(level.toObject()));
Level level;
level.read(levelObject);
mLevels.append(level);
}
} }
} }
//! [1] //! [read]
//! [2] //! [toJson]
void Game::write(QJsonObject &json) const QJsonObject Game::toJson() const
{ {
QJsonObject playerObject; QJsonObject json;
mPlayer.write(playerObject); json["player"] = mPlayer.toJson();
json["player"] = playerObject;
QJsonArray levelArray; QJsonArray levels;
for (const Level &level : mLevels) { for (const Level &level : mLevels)
QJsonObject levelObject; levels.append(level.toJson());
level.write(levelObject); json["levels"] = levels;
levelArray.append(levelObject); return json;
}
json["levels"] = levelArray;
} }
//! [2] //! [toJson]
void Game::print(int indentation) const void Game::print(int indentation) const
{ {

View File

@ -26,7 +26,7 @@ public:
bool saveGame(SaveFormat saveFormat) const; bool saveGame(SaveFormat saveFormat) const;
void read(const QJsonObject &json); void read(const QJsonObject &json);
void write(QJsonObject &json) const; QJsonObject toJson() const;
void print(int indentation = 0) const; void print(int indentation = 0) const;
private: private:

View File

@ -25,39 +25,37 @@ void Level::setNpcs(const QList<Character> &npcs)
mNpcs = npcs; mNpcs = npcs;
} }
//! [0] //! [fromJson]
void Level::read(const QJsonObject &json) Level Level::fromJson(const QJsonObject &json)
{ {
if (json.contains("name") && json["name"].isString()) Level result;
mName = json["name"].toString();
if (json.contains("npcs") && json["npcs"].isArray()) { if (const QJsonValue v = json["name"]; v.isString())
QJsonArray npcArray = json["npcs"].toArray(); result.mName = v.toString();
mNpcs.clear();
mNpcs.reserve(npcArray.size()); if (const QJsonValue v = json["npcs"]; v.isArray()) {
for (const QJsonValue &v : npcArray) { const QJsonArray npcs = v.toArray();
QJsonObject npcObject = v.toObject(); result.mNpcs.reserve(npcs.size());
Character npc; for (const QJsonValue &npc : npcs)
npc.read(npcObject); result.mNpcs.append(Character::fromJson(npc.toObject()));
mNpcs.append(npc);
}
} }
}
//! [0]
//! [1] return result;
void Level::write(QJsonObject &json) const }
//! [fromJson]
//! [toJson]
QJsonObject Level::toJson() const
{ {
QJsonObject json;
json["name"] = mName; json["name"] = mName;
QJsonArray npcArray; QJsonArray npcArray;
for (const Character &npc : mNpcs) { for (const Character &npc : mNpcs)
QJsonObject npcObject; npcArray.append(npc.toJson());
npc.write(npcObject);
npcArray.append(npcObject);
}
json["npcs"] = npcArray; json["npcs"] = npcArray;
return json;
} }
//! [1] //! [toJson]
void Level::print(int indentation) const void Level::print(int indentation) const
{ {

View File

@ -21,8 +21,8 @@ public:
QList<Character> npcs() const; QList<Character> npcs() const;
void setNpcs(const QList<Character> &npcs); void setNpcs(const QList<Character> &npcs);
void read(const QJsonObject &json); static Level fromJson(const QJsonObject &json);
void write(QJsonObject &json) const; QJsonObject toJson() const;
void print(int indentation = 0) const; void print(int indentation = 0) const;
private: private: