Tip of the Week #99: Nonmember Interface Etiquette

Originally posted as totw/99 on 2015-06-24

Revised 2017-10-10

The interface of a C++ class is not constrained to its members or to its definition. When evaluating an API, we must consider definitions beyond the class body that can be as much a part of its interface as its public members.

These external interface points include template specializations like hashers or traits, nonmember operator overloads (e.g. logging, relationals), and other canonical nonmember functions designed for use with argument-dependent lookup (ADL), most notably swap().

A few of these are illustrated below for some sample class space::Key:

namespace space {
class Key { ... };

bool operator==(const Key& a, const Key& b);
bool operator<(const Key& a, const Key& b);
void swap(Key& a, Key& b);

// standard streaming
std::ostream& operator<<(std::ostream& os, const Key& x);

// gTest printing
void PrintTo(const Key& x, std::ostream* os);

// new-style flag extension:
bool ParseFlag(const string& text, Key* dst, string* err);
string UnparseFlag(const Key& v);

}  // namespace space

HASH_NAMESPACE_BEGIN
template <>
struct hash<space::Key> {
  size_t operator()(const space::Key& x) const;
};
HASH_NAMESPACE_END

There are some important risks associated with making such extensions incorrectly, so this article will try to present some guidance.

The Proper Namespace

Interface points that are functions are usually designed to be found by argument-dependent lookup (ADL, see TotW 49). Operators and some operator-like functions (notably swap()) are designed to be found by ADL. This protocol only works reliably when the function is defined in a namespace associated with the type being customized. The associated namespaces include those of its base classes and class template parameters. A common mistake is to place these functions in the global namespace. To illustrate the problem, consider the following code in which good(x) and bad(x) functions are called with identical syntax:

namespace library {
struct Letter {};

void good(Letter);
}  // namespace library

// bad is improperly placed in global namespace
void bad(library::Letter);

namespace client {
void good();
void bad();

void test(const library::Letter& x) {
  good(x);  // ok: 'library::good' is found by ADL.
  bad(x);  // oops: '::bad' is hidden by 'client::bad'.
}

}  // namespace client

Note the difference between library::good() and ::bad(). The test() function is relying on the absence of any function called bad() in namespaces enclosing the call site. The appearance of client::bad() hides ::bad() from the test caller. Meanwhile, the good() function is found regardless of what else exists in the test() function’s enclosing scope. The C++ name lookup sequence will only yield a global if a name search from closer lexical scopes to the call site fails to find any names.

This is all very subtle, which is the point, really. It’s all much simpler if we default to defining functions alongside the data on which they operate.

A Quick Note on In-Class Friend Definitions

There’s a way to add non-member functions to a class from within the class definition. Friend functions can be defined directly inside a class.

namespace library {
class Key {
 public:
  explicit Key(string s) : s_(std::move(s)) {}
  friend bool operator<(const Key& a, const Key& b) { return a.s_ < b.s_; }
  friend bool operator==(const Key& a, const Key& b) { return a.s_ == b.s_; }
  friend void swap(Key& a, Key& b) {
    swap(a.s_, b.s_);
  }

 private:
  std::string s_;
};
}  // namespace library

These friend functions have a special property of ONLY being visible through ADL. They’re a little strange in that they are defined in the enclosing namespace, but don’t appear in name lookup there. These in-class friend definitions must have inlined bodies for this stealth property to kick in. See “Friend Definitions” for more detail.

Such functions will not hide global functions from fragile call sites in their namespace or appear in diagnostic messages for unrelated calls to functions of the same name. They get out of the way, essentially. They’re also very convenient to define, easy to discover, and they have access to class internals. Probably the biggest drawback is that they will not be found in cases where the enclosing class is implicitly convertible from an argument type.

Note that access (i.e. public, private, and protected) has no effect on friend functions, but it might be polite to place these in the public section anyway, just so they’ll be more visible during perusal of the public API points.

The Proper Source Location

To avoid violations of the one-definition rule (ODR), any customizations on a type’s interface should appear where they can’t accidentally be defined multiple times. This usually means they should be packaged with the type in the same header. It’s not appropriate to add this sort of nonmember customization in a *_test.cc file, or in a “utilities” header off to the side where it can be overlooked. Force the compiler to see the customization and you will be more likely to catch violations.

A function overload (including operator overloads) intended as a nonmember extension should be declared in a header that defines one of its arguments.

The same goes for template specializations. Template specializations can be packaged with the primary template definition, or packaged with the type on which it’s being specialized. For partial specializations or with multiple parameters it’s a judgment call. It’s usually pretty clear in practice which site is better. The important thing is that the specialization should not be hidden in client code: it should be as visible as the template and the user-defined types involved.

When to Customize

Don’t customize the behavior of a class in a test. This is dangerous and unfortunately very common. Test source files are not immune to these dangers and should follow the same rules as production code. We find a lot of inappropriate operators in *_test.cc files that are written with the intent to “get EXPECT_EQ to compile” or some other pragmatic concern. Unfortunately they are still ODR risks (if not violations) and can make library maintenance difficult. These rogue definitions might even stand in the way of library maintenance as adding these operators upstream would break the very tests that needed them and defined their own. For testing there are alternatives available with a little more effort.

Note that ADL works from the original declaration point for a type. Typedefs, type aliases, alias templates, and using declarations do not create types and have no impact on ADL. This can make it a little tricky to locate the proper placement for customizations, but this cannot be helped, so just do it.

Don’t augment the interface to types generated by protobufs. This is another common pitfall. You may own the .proto file, but that doesn’t mean you own the C++ API it generates, and your augmentations could block improvements to the generated C++ API. This is an ODR risk because you can’t ensure that your augmentations are seen whenever the generated header is included.

When defining T, it may be tempting to define behavior for templates like std::vector<T> or std::pair<T,T>. Though your customizations may take precedence and may do what you expect, you may be conflicting with other expected customizations defined on the broader class template.

It’s possible to define some customizations for raw pointers. It may be tempting in some cases to supply these customizations for T* along with T. This is not advised. It’s dangerous because the customization may conflict with the expected ordinary behavior of pointers (e.g. the way they’re logged, swapped, or compared). It’s best to leave pointers alone.

What to Do When You’re Stuck

Following these guidelines can be challenging. Much of the inappropriate overloading and specialization seen in C++ code is motivated by a small set of root causes. Below is a partial listing with successful workarounds. If you run into a library API that can’t be worked with, please send a note to the owners to see about adding the appropriate customization hooks. Common APIs should be usable without breaking these interface packaging guidelines.

Testing a Type with EXPECT_EQ, etc.

temptation: EXPECT_EQ requires operator==, and operator<< or GoogleTest’s PrintTo.

workaround: Write lightweight gmock matchers with MATCHER_P instead of relying exclusively on EXPECT_EQ etc.

workaround: Create local (this is essential) wrapper types you DO truly own and provide customizations on those, possibly using trivial inheritance as a shortcut.

Using T as a Container Key

temptation: Container default functor types may rely on operator<, operator==, and hash<T>.

workaround: Use more custom comparators or custom hashers. Use more typedefs for your associative container types to hide these details from client code.

Logging Containers of T

temptation: Defining operator<< overloads for standard containers.

workaround: Don’t try to log the container directly.

Take-Aways

A type’s behavior is not completely defined by its class definition. Non-member definitions and specializations also contribute. You may need to keep reading past that closing brace to really understand how a class works.

Be aware of when and where it’s safe to add these customizations. Adding inappropriate definitions may get your code to work for now, but you could be adding fragility and maintenance blockers for other engineers down the line to deal with.


Subscribe to the Abseil Blog