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
Pick-to: 6.5 6.4 6.2
Change-Id: Icaa14acc7464fef00a59534679d710252e921383
Reviewed-by: Qt CI Bot <qt_ci_bot@qt-project.org>
Reviewed-by: Mårten Nordheim <marten.nordheim@qt.io>
This commit is contained in:
Marc Mutz 2023-02-07 13:52:50 +01:00
parent a7d92f809f
commit 5a3ac484db
7 changed files with 166 additions and 113 deletions

View File

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

View File

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

View File

@ -24,45 +24,81 @@
The Character class represents a non-player character (NPC) in our game, and
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
Of particular interest to us are the read and write function
Of particular interest to us are the fromJson() and toJson() function
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
QJsonObject argument. You can use either \l QJsonObject::operator[]() or
QJsonObject::value() to access values within the JSON object; both are
const functions and return QJsonValue::Undefined if the key is invalid. We
check if the keys are valid before attempting to read them with
QJsonObject::contains().
In the fromJson() function, we construct a local \c result Character object
and assign \c{result}'s members values from the QJsonObject argument. You
can use either \l QJsonObject::operator[]() or QJsonObject::value() to
access values within the JSON object; both are const functions and return
QJsonValue::Undefined if the key is invalid. In particular, the \c{is...}
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
values from the Character object to the JSON object. As with accessing
values, there are two ways to set values on a QJsonObject:
\l QJsonObject::operator[]() and QJsonObject::insert(). Both will override
any existing value at the given key.
Observe the use of
\l{https://en.cppreference.com/w/cpp/language/if#If_statements_with_initializer}
{C++17 if-with-initializer} to separate scoping and checking of the variable \c v.
This means we can keep the variable name short, because its scope is limited.
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
We want to have several levels in our game, each with several NPCs, so we
keep a QList of Character objects. We also provide the familiar read() and
write() functions.
We want the levels in our game to each each have several NPCs, so we keep a QList
of Character objects. We also provide the familiar fromJson() and toJson()
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
\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
read their JSON and be appended to our NPC array.
toObject() to get the Character's JSON object. Character::fromJson() can
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
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
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.
\section1 The Game Class
Having established the Character and Level classes, we can move on to
the Game class:
@ -86,26 +124,43 @@
Next, we provide accessors for the player and levels. We then expose three
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
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
itself. We then clear the level array so that calling loadGame() on the
same Game object twice doesn't result in old levels hanging around.
The read() function starts by replacing the player with the
one read from JSON. We then clear() the level array so that calling
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.
\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
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
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
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
QJsonDocument, and call either QJsonDocument::toJson() or to
QJsonDocument::toBinaryData() to save the game, depending on which format
was specified.
QJsonObject. To save the game in the format that was specified, we
convert the JSON object into either a QJsonDocument for a subsequent
QJsonDocument::toJson() call, or a QCborValue for QCborValue::toCbor().
\section1 Tying It All Together
We are now ready to enter main():

View File

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

View File

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

View File

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

View File

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