Tip of the Week #177: Assignability vs. Data Member Types

Originally posted as TotW #177 on April 6, 2020

By Titus Winters

Updated 2020-04-06

Quicklink: abseil.io/tips/177

When implementing a type, decide on type design first. Prioritize API over implementation details. One common example of this is the tradeoff between assignability of a type vs. qualifiers for data members.

Deciding how to represent data members

Imagine you are writing a City class, and discussing how to represent its member variables. You know that it is short-lived, representing the city as a snapshot in time, so things like population, name, and mayor could conceivably be const - we aren’t going to use the same object in a given program for years and years, so we don’t need to account for changes in population, new census results, or elections.

Should we have members like this?

 private:
  const std::string city_name_;
  const Person mayor_;
  const int64 population_;

Why or why not?

The common suggestion for “Yes, make those const” hinges on the idea “Well, those values aren’t going to change for a given City, so since everything that can be const should be const, make them const.” That will make it easier for maintainers of the class to avoid accidentally modifying those fields.

This misses a critically important concern: what sort of type is City? Is this a value? Or a bundle of business logic? Is it expected to be copyable, move-only, or non-copyable? The set of operations you can write efficiently for City (as a whole) may be impacted by the question of whether a single member is made const, and that is often a bad tradeoff.

Specifically, if your class has const members, it cannot be assigned to (whether by copy-assignment or move-assignment). The language understands this: if your type has a const member, copy-assignment and move-assignment operators will not be synthesized. You can still copy (or move) construct such an object, but you cannot change it in any way after construction (even “just” to copy from another object of the same type). Even if you write your own assignment operators, you’ll quickly find that you (obviously) can’t overwrite these const members.

So it is possible that the question becomes “Which should we prefer: const members or assignment operations?” However, even that is misleading, because both are answered by the one important question, “What sort of type is City?” If it is intended to be a value type, that specifies the API (including assignment operations), and API trumps implementation concern in general.

It is important for those API design decisions to take priority over implementation-detail choices: in the general case, there are more engineers affected by the API of a type than implementation of a type. That is, there are more users of a type than maintainers of that type, so priority should go to design choices that affect the user above the implementer. Even if you think the type will never be used by anyone outside of the team that is maintaining it, software engineering is about interface design and abstraction - we should be prioritizing good interfaces.

Reference Members

The same reasoning applies to storing references as data members. Even if we know that the member must be non-null, it is still usually preferable to store T* for value types, because references are not rebindable. That is, we cannot re-point a T& - any modifications of such a member are modifying the underlying T.

Consider the implementation of std::vector<T>. There will almost certainly be a T* data member in any std::vector implementation, pointing to the allocation. We know from the specification of std::vector that such an allocation must usually be valid (except possibly for empty vectors). An implementation that always has an allocation could make that T&, right? (Yes, I’m ignoring arrays and offsets here.)

Clearly not. std::vector is a value type, it is copyable and assignable. If the allocation was stored with a reference-to-the-first-member instead of pointer-to-the-first-member, we wouldn’t be able to move-assign the storage, and it’s unclear how we’d update data when resizing normally. Our clever way of telling other maintainers “This value is non-null” would be getting in the way of providing users the desired API. Hopefully it is clear that this is the wrong tradeoff.

Non-copyable / assignable types

Of course, if your choices about type design suggest that City (or whatever type you are thinking about) should be non-copyable, that leaves far fewer constraints on your implementation. It isn’t right or wrong for a class to hold const or reference members, it’s only a concern when those implementation decisions are constraining or corrupting the interface presented by that class. If you’ve already made a thoughtful and conscious decision that your type need not be copyable, it’s very reasonable for you to make different choices about how to represent the data members of the class. (But see Tip #116 for some more thoughts and pitfalls around argument lifetime and reference storage).

The Unusual Case: immutable types

There is one useful-but-unusual design that may mandate const members: intentionally immutable types. Instances of such a type are immutable after construction: no mutating methods, no assignment operators. These are fairly rare, but can sometimes be useful. In particular, such a type is inherently thread-safe because there are no mutating operations. Objects of such a type can be freely shared among threads with no concern about data races or synchronization. However, in exchange these objects may have significant run-time overhead stemming from the need to copy them constantly. The immutability even prevents these objects from being efficiently moved.

It is almost always preferable to design your type to be mutable but still thread-compatible, rather than relying on thread-safety-via-immutability. Users of your type are usually in a better position to judge the benefits of mutability case-by-case. Don’t force them to work around unusual design choices without very strong evidence showing why your use case is unusual.

Recommendations

  • Decide on the design of your type before considering implementation details.
  • Value types are common and recommended. So are business-logic types, which are often non-copyable.
  • Immutable types are sometimes useful, but the cases where they are justified are fairly rare.
  • Prioritize API design and the needs of users over the (usually smaller) concerns of maintainers.
  • Avoid const and reference data members when building value types or move-only types.

Subscribe to the Abseil Blog