C++ API design: Clearing up public interface
Answering my own question: This idea is based on the interface - implementation relationship, where the public API is explicitly defined as the interface, while the implementation details reside in a separate class extending it, inaccessible to the user, but accessible to the rest of the library.
Halfway through implementing static polymorphism using CRTP as πάντα ῥεῖ suggested to avoid virtual call overhead, I realized polymorphism is not actually needed at all for this kind of design, as long as only one type will ever implement the interface. That makes any kind of dynamic dispatch pointless. In practice, this means flattening all the ugly templates you get from static polymorphism and ending up with something very simple. No friends, no templates, (almost) no virtual calls. Let's apply it to the example above:
Here is the header, containing just the public API with example usage:
class CookieJar {
public:
static std::unique_ptr<CookieJar> Create(unsigned capacity);
bool isEmpty();
void fill();
virtual ~CookieJar() = 0 {};
};
class CookieMonster {
public:
void feed(CookieJar* cookieJar);
bool isHungry();
};
void main() {
std::unique_ptr<CookieJar> jar = CookieJar::Create(20);
jar->fill();
CookieMonster monster;
monster.feed(jar.get());
}
The only change here is turning CookieJar
into an abstract class and using a factory pattern instead of a constructor.
The implementations:
struct Cookie {
const bool isYummy = true;
};
class CookieJarImpl : public CookieJar {
public:
CookieJarImpl(unsigned capacity) :
capacity(capacity) {}
bool isEmpty() {
return count == 0;
}
void fill() {
count = capacity;
}
Cookie getCookie() {
if (!isEmpty()) {
count--;
return Cookie();
} else {
throw std::exception("Where did all the cookies go?");
}
}
private:
const unsigned capacity;
unsigned count = 0;
};
// CookieJar implementation - simple wrapper functions replacing dynamic dispatch
std::unique_ptr<CookieJar> CookieJar::Create(unsigned capacity) {
return std::make_unique<CookieJarImpl>(capacity);
}
bool CookieJar::isEmpty() {
return static_cast<CookieJarImpl*>(this)->isEmpty();
}
void CookieJar::fill() {
static_cast<CookieJarImpl*>(this)->fill();
}
// CookieMonster implementation
void CookieMonster::feed(CookieJar* cookieJar) {
while (isHungry()) {
static_cast<CookieJarImpl*>(cookieJar)->getCookie();
}
}
bool CookieMonster::isHungry() {
return true;
}
This seems like a solid solution overall. It forces using a factory pattern and if you need copying and moving, you need to define the wrappers yourself in a similar fashion to the above. That is acceptable for my use case, since the classes I needed to use this for are heavyweight resources anyway.
Another interesting thing I noticed is that if you feel really adventurous, you can replace static_casts with reinterpret_casts and as long as every method of the interface is a wrapper you define, including the destructor, you can safely assign any arbitrary object to an interface you define. Useful for making opaque wrappers and other shenanigans.
Consider the following code:
struct Cookie {};
struct CookieJarData {
int count;
int cost;
bool whatever;
Cookie cookie;
};
struct CookieJarInternal {
CookieJarInternal(CookieJarData *d): data{d} {}
Cookie getCookie() { return data->cookie; }
private:
CookieJarData *data;
};
struct CookieJar {
CookieJar(CookieJarData *d): data{d} {}
int count() { return data->count; }
private:
CookieJarData *data;
};
template<typename... T>
struct CookieJarTemplate: CookieJarData, T... {
CookieJarTemplate(): CookieJarData{}, T(this)... {}
};
using CookieJarImpl = CookieJarTemplate<CookieJar, CookieJarInternal>;
class CookieMonster {
public:
void feed(CookieJarInternal &cookieJar) {
while (isHungry()) {
cookieJar.getCookie();
}
}
bool isHungry() {
return false;
}
};
void userMethod(CookieJar &cookieJar) {}
int main() {
CookieJarImpl impl;
CookieMonster monster;
monster.feed(impl);
userMethod(impl);
}
The basic idea is to create a class that is at the same time the data and derives from a bunch of subclasses.
Because of that, the class is its subclasses and you can use them whenever you want by choosing the right type.
This way, the combining class has a full interface and is built up if a few components that share the same data, but you can easily return a reduced view of that class that still doesn't have virtual methods.