The access-by idiom in C++

c++
design

Useful to unit-test private methods for when you have no better way.

Author

Bimal Gaudel

Published

April 20, 2022

The problem

Recently I had to write a cache manager in C++, a wrapper class around a map data structure. The cache manager had methods to store and access data using, surprise, keys! The important design decision about the cache manager class was that it hid the underlying map structure. When it came time to unit-test it, I found I needed a const reference to that map to test the cache thoroughly.

I could think of two solutions: add a const method to the class that would return a reference to the underlying map, or add a friend class or function to grant access to the private members. Neither option seemed like a good approach.

  • A const accessor method would let any user bypass the cache manager’s checked access, defeating the point of having the manager in the first place.

  • Adding a friend function or class just to enable unit-testing didn’t feel right either: it makes the header depend on the tests, as if the class were incomplete without them.

Then I found this solution, which refines the second option above.

Solution

Let’s look at it with an example: a basic cache manager that stores std::string values keyed by size_t.

namespace nspc { // some namespace
class CacheManager {
 private:
  std::map<size_t, std::string> cache_;
 public:
  // constructors
  // method: store data
  // method: access data

  template <typename T> struct access_by;
  template <typename T> friend struct access_by;
};
} // namespace nspc

For unit-testing, we specialize the access_by template we declared (and befriended) inside CacheManager:

namespace nspc { // specialization happens in the same namespace

struct ConstAccessToCache{};

template <>
struct CacheManager::access_by<ConstAccessToCache> {
  // A function that returns a const reference to the
  // CacheManager's private member cache_
  [[nodiscard]] static auto const& cache(CacheManager const& cm) {
    return cm.cache_;
  }
};
} // namespace nspc

Because access_by is befriended, every specialization of it is a friend too, so the accessor can read the private cache_. The header declares this extension point once and never changes again: each test defines its own accessor out of line, and production code never names a test type. Unlike a plain friend class, it needs no test-only friends in the header, so the class stays complete on its own.

A demo main.cpp:

#include <cstddef>
#include <map>
#include <string>
// CacheManager class definition from above
// CacheManager::access_by specialization from above
int main(int argc, char* argv[]) {

  auto man = nspc::CacheManager{};

  // access the cache_ member through the access_by specialization
  auto const& cache =
      decltype(man)::access_by<nspc::ConstAccessToCache>::cache(man);

  // now use cache for testing

  return 0;
}