2

The C++ "standard" method for serializing and deserializing a data type is to use streams with the insertion (<<) and extraction (>>) operators. This has some flaws, but it does neatly allow you to use the same semantics to write anything to/read anything from a stream simply by overloading the operators.

However, I see one major problem (as with many deserialization frameworks). How can you preserve the invariants of complex types?

Let's take the following simple type as an example:

class MyType
{
public:
    MyType(int u):
        underlying_(u)
    {
        if (u > 10) throw std::invalid_argument{};
    }

    auto value() const { return _underlying; }

private:
    int underlying_;
};

Essentially, this class defines a type which, if it exists, it is guaranteed to have a "value" less than 10. In other words, it is not possible to construct the type with an invalid value.

The insertion operator is simple to implement:

std::ostream& operator<<(std::ostream& os, const MyType& v)
{
    os << v.value();
    return os;
}

However, I do not understand how to write the extraction operator:

std::istream& operator>>(std::istream& is, MyType& v)
{
    ????
}

There are two problems here:

  1. The extraction operator assumes an instance of MyType already exists into which the data is read
  2. Simply reading the value directly into _underlying could break the invariants

The "solution" to (1) is usually to introduce a default constructor, but this breaks the whole idea of the class invariants. I want an instance of the class to not exist unless it has a valid value.

To solve (2), I've seen the operators being added as friend to the class, but this introduces the potential for invariant breaking (essentially, the stream is directly modifying the internals of the type).

What are some possible solutions to these problems? Is there a "standard" solution?

As an aside, an even better example would be a enum class. An enum class is a strong type with a set of valid values and invalid values. Since the enum is implemented as a primitive data type, it could store any value that the underlying type allows, but only a subset of these values are allowed. When used properly, the compiler prevents you from setting an invalid value. However, deserializing directly into an enum (perhaps by using a cast) would break this checking (i.e., break the invariants).

3
  • 1
    @PhilipKendall Whoops... Yes, I fixed it. Oct 20, 2023 at 19:04
  • You could implement extraction for optional<MyType>, which gets rid of the default value problem. Within the function you can read into local vars first. But this aspect of the stdlib isn't particularly well designed, and you might not want to implement istream operators in the first place. Regardless of extraction, it is unfortunately extremely common for C++ types to have an "empty" state. But this also allows them to be moved efficiently, as moves are often implemented as a swap with the default value. Btw all values of the underlying type are valid for an enum, not just the named values.
    – amon
    Oct 21, 2023 at 5:58
  • @amon I just took a look at the standard and indeed, any value is technically legal for an enum (even a strongly-typed enum class). I don't necessary agree with this and, in all my uses cases for enums, my assertion that it shall not contain values other than those defined in the enum, is true. Oct 23, 2023 at 13:41

2 Answers 2

7

The "solution" to (1) is usually to introduce a default constructor, but this breaks the whole idea of the class invariants. I want an instance of the class to not exist unless it has a valid value.

Who says a default contructor cannot create an instance of the class with a valid value? In most real world cases I have seen, it wasn't hard to find a more or less sensible "default" value, regardless of any invariants. And when that's not possible, ">>" is probably the wrong tool.

Simply reading the value directly into _underlying could break the invariants

What about

std::istream& operator>>(std::istream& is, MyType& v)
{
    int u;
    is >> u;
    MyType tmp(u);
    v=tmp;
}

This will make use of the invariant test inside the constructor, throw an exception when it is violated, and when the value is valid, it will be assigned to v. As you see, no friend declaration necessary, only an implicit or explicit assignment operator.

4

Designing deserialisation

It is vital not to confuse means (extraction operator) with objective (deserialization):

  • There are plenty of ways to use extraction operator in a meaningful way to deserialise, including ensuring invariants. But the overload should correspond to the usual C++ idioms.

  • If you don't want objects to be constructed with a default value before deserialisation, you'd better go for a factory like design: where the factory function uses a stream as parameter and return objects by value. (don't consider a constructor from istream). Use a factory returning a smart pointer for polymorphic objects.

  • Prefer reusing one of the proven libraries to save you time (boost, cereal and others, as mentioned by Basile in the comments).

The C++ extraction operator

The extraction operator assumes that the input to be extracted from, is a valid representation of the object to be extracted (correct format) AND the value extracted meets all the invariants.

You can design your operator overload in two ways, being understood that the target object must already exist and be a valid MyType:

  • directly altering v, calling setters and other operations to change the state of the object to the desired state. In this case you do not need to be friend, and could restrict yourself to use strictly the public interface.
  • constructing a temporary object and copying (or moving?) it to v. Ideally, you would call a specific constructor and complement it with additional public operations (this is in the spirit of LSP's history rule). If you chose the friend approach, you have more flexibility, but also more risks.

In both cases, if something goes wrong, the operator should ideally set the failbit (which will cause a throw or not, depending on the settings of the stream). If you throw directly, it'll work, but some users might be surprised depending how they use the istreams in their code.

Here an example implementation:

std::istream& operator>> (std::istream &is, MyType &v) {
    int in; 
    is>>in; 
    try {
        v = MyType(in);
    } catch(...) {
        is.setstate(std::ios::failbit);;
    }
    return is; 
} 
6
  • 1
    Several C++ serialization open source libraries exist, eg cereal or alpaca Oct 21, 2023 at 17:33
  • 1
    @BasileStarynkevitch yes indeed. Boost as well. Thanks for mentioning them. I didn't because the question seemed to be more about the extraction operator and invariants, i.e. in particular about the use of friends. Edited to complete :-)
    – Christophe
    Oct 21, 2023 at 20:01
  • @Christophe Could you point me to where "The extraction operator assumes that the input to be extracted from, is a valid representation of the object to be extracted (correct format) AND the value extracted meets all the invariants" is defined in the standard? This is a VERY important caveat I hadn't considered. Which potentially makes the stream operators not so suitable for, for example, deserialization from a use input which could have invalid values (in my opinion). Oct 23, 2023 at 13:44
  • @PatrickWright you will not find this written as is in the standard. The standard does not define operator>> for a stream and a user type. However, doing so is atypical C++ idiom. What you will find however in the standard, is the definition of operator>>(std::stream, ...) for a number of build in type (section 29.7.4.2.2 of C++20). It shows that it always bases the result on the parsing of the input characters in the context of a locale (that's about format). It also shows that for each integral type, the operator performs invariant checks (i.e. number in the min ..max range defined for
    – Christophe
    Oct 23, 2023 at 22:04
  • the numeric type or otherwise set the failbit. This is the base of the idiom, i.e. implementations for custom types generally follow this convention if there is a desire to meet the expectations of C++ programmers being used to the standard library. It's a kind of specification by example.
    – Christophe
    Oct 23, 2023 at 22:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.