Why it matters
An interface is the list of things an object promises to do. In C++ an interface is usually an abstract class: a class that has one or more pure virtual functions. A pure virtual function is a method with = 0 at the end. It has no body, so any class that wants to be used as the interface must write its own version of the method.
A good interface lets the code that uses an object stay the same when the object changes. A report that reads room temperatures does not need to know whether the temperature comes from a real sensor or a fake one used in tests. It only needs a way to ask for a number. If the interface offers exactly that and nothing more, the report code is short, and a new sensor type can be added without touching the report.
A bad interface is wide. It lists many methods, most callers use one or two of them, and every class that implements it must still write all of them. The rest of this lesson shows how to design the small version. The earlier lessons in this module covered virtual functions and inheritance. This lesson is about deciding what goes into the interface and what stays out.
The idea: start from the caller
Think of a power socket. A socket offers one promise: it gives out electricity at a set voltage. A phone charger, a lamp and a kettle all plug into the same socket. The socket does not offer a button to make toast, because a socket is not a toaster. If the socket tried to do everything, every device would need to fit all of its buttons, and most of them would never use them.
Design an interface the same way. Begin with the code that will call the object, and write down what that code does with the object. Then include only those actions in the interface. If a caller needs to read a value, the interface has a method to read it. If nothing calls a method for writing, the interface does not have one yet.
This order matters. Many people begin with the object they have, list every method it has, and copy the whole list into the interface. The result is an interface shaped like one class, not like the work the callers do. Starting from the caller keeps the interface shaped by use.
Example 1: a report that needs one number
Here is a small program. The print_report function prints a room name and a temperature. It needs exactly one thing from the source of the temperature: a method that returns a number. The interface is named TemperatureSource and has one pure virtual function.
Expected output:
Kitchen: 21.5 C
Test room: 18 CLook at what the report function does not know. It does not know the name RoomSensor. It does not know how the number is produced. It can be given any object that has read_celsius, and the test can pass a FixedSource with a value it chose. This is the main benefit of a small interface: the test does not need a real sensor, and the report does not need a real sensor to be tested.
Some details in the code are worth a close look.
The word virtual means that a call through the interface runs the method of the real object. When print_report calls source.read_celsius(), C++ looks at the object that was passed in, which is a RoomSensor or a FixedSource, and runs that object's version of the method.
The override word after a method asks the compiler to check that the method really replaces a method in the base class. If the name or the signature does not match, the compiler reports an error. The mistakes section below shows what that error looks like.
The virtual ~TemperatureSource() = default; line is a virtual destructor. It makes sure that deleting a sensor through an interface pointer cleans up the whole object. Every interface in this lesson has one. The module lesson on polymorphism covers why.
Example 2: a wide interface and what it costs
Now imagine the storage layer of a small app. A developer adds every method they can think of to the interface, so that the interface looks complete. The program below is written that way. It has six pure virtual functions. Only read is used by the caller in show_greeting.
Expected output:
helloThe program works, but look at the cost. MemoryStorage has to write backup and compact even though nothing in the program calls them. The two empty bodies {} do nothing, so a later developer may think they do something. If a real storage type had to implement backup, it would need a real backup routine that someone must test and keep working, for a feature that no caller uses.
The show_greeting function is also tied to the whole interface. It only reads, but it accepts a Storage and therefore depends on write, remove, list_keys, backup and compact too. If someone changes the signature of backup, the greeting code is affected, even though it never calls backup.
A wide interface costs in three places. Every implementation must write every method. Every caller depends on every method, so a change to one method can reach code that never used it. And a reader has to read more to learn what the caller actually needs. The fix is to make the interface match the caller, as the next example shows.
Example 3: split the interface by what callers need
The fix for a wide interface is not always a single narrow interface. Often the cleanest design is two or three small interfaces, each for one kind of caller. Reading and writing are different jobs. A function that copies data needs to read from one place and write to another, but it does not need to know how the places store their data.
This program defines two small interfaces, LineReader and LineWriter. A LineBuffer class implements both. The copy_lines function needs both, so it takes both. The count_lines function only reads, so it takes only the reader.
Expected output:
copied 3 lines
alpha
beta
gamma
count 2Some parts of this code need explaining.
std::optional<std::string> is a value that either holds a string or holds nothing. next_line returns a line when there is one, and std::nullopt, which means nothing, when the reader has run out. The while (auto line = from.next_line()) loop uses that: it keeps going while the optional holds a value. This is a common C++ pattern for "read until there is nothing left", and it avoids a separate has_more method that a caller could forget to check.
LineBuffer inherits from two bases: LineReader and LineWriter. A class may do this in C++. Each base brings its own pure virtual functions, and LineBuffer writes both sets. This is the way to let one object satisfy two small interfaces at once.
copy_lines takes its parameters by non-const reference because it changes the state of both objects. Reading moves the position forward, and writing adds a line. A function that only read would take const references instead, and count_lines takes its reader by non-const reference for the same reason: next_line changes the position.
Notice that count_lines cannot write, even by mistake. It has no LineWriter parameter, so a line such as target.write_line(...) inside it would not compile. The narrow interface makes the rule part of the function signature.
How to find a method nobody calls
Once an interface exists, it is easy to add a method "in case it is needed." This habit makes interfaces wide over time. Here are three checks that catch methods nobody uses.
First, search the project for each method name. If the name appears only in its own class and in the interface, no caller uses it. A search tool such as grep can do this in one command. The result is a list of places where the name is written, and if the list has only the definitions, the method is unused.
Second, ask who will call the method, and what they will do with the answer. If the honest answer is "maybe someone later," leave the method out. Adding it later costs one line in the interface and one in each implementation. Keeping it now costs the same lines, plus the work of keeping it correct for as long as it exists.
Third, watch for a method that exists only to make a test easier. A test may need a way to reset state, but a reset method on the real interface is a risk. If a test needs it, the usual approach is a second, test-only class that adds the method, and the real interface stays as small as the production code requires.
A method that is removed later is also easy to remove, because the compiler lists every place that still uses it. Removing an unused method from an interface is safe when the code compiles with -Wall and all the tests still pass.
Common mistakes
A typo in an override name
When a method name in a derived class does not match the base class exactly, the override word turns the typo into a compile error. The file below has a misspelled method name, read_celcius, where the interface has read_celsius.
#include <iostream>
class TemperatureSource {
public:
virtual ~TemperatureSource() = default;
virtual double read_celsius() const = 0;
};
class BrokenSensor : public TemperatureSource {
public:
// Typo in the name: the base method is read_celsius, so this is a new method.
double read_celcius() const override { return 20.0; }
};
int main() {
TemperatureSource source;
BrokenSensor sensor;
std::cout << sensor.read_celsius() << "\n";
return 0;
}Running g++ -std=c++20 -Wall broken_typo.cpp -o a1 prints the following. The output is shortened after the first two errors.
broken_typo.cpp:12:12: error: 'double BrokenSensor::read_celcius() const' marked 'override', but does not override
12 | double read_celcius() const override { return 20.0; }
| ^~~~~~~~~~~~
broken_typo.cpp:16:23: error: cannot declare variable 'source' to be of abstract type 'TemperatureSource'
16 | TemperatureSource source;
| ^~~~~~The first error is the one to fix. The second error is for source, because TemperatureSource is abstract and cannot be created. The compiler also reports sensor, because BrokenSensor does not implement read_celsius, so it is abstract too. Fixing the spelling removes all of them.
A missing const
A method that is marked const in the interface must be marked const in every class that implements it. Leaving out the word is an easy mistake, and it has a confusing result. The next file does not compile. The method without const is a new method, so it does not replace the base version, and ForgotConst stays abstract.
#include <iostream>
class TemperatureSource {
public:
virtual ~TemperatureSource() = default;
virtual double read_celsius() const = 0;
};
class OkSensor : public TemperatureSource {
public:
double read_celsius() const override { return 20.0; }
};
class ForgotConst : public TemperatureSource {
public:
// Missing const: this does not override read_celsius() const.
double read_celsius() override { return 19.0; }
};
int main() {
OkSensor ok;
std::cout << ok.read_celsius() << "\n";
ForgotConst bad;
std::cout << bad.read_celsius() << "\n";
return 0;
}Running g++ -std=c++20 -Wall broken_const.cpp -o a2 prints these lines, with the file name as it was saved.
broken_const.cpp:17:12: error: 'double ForgotConst::read_celsius()' marked 'override', but does not override
17 | double read_celsius() override { return 19.0; }
| ^~~~~~~~~~~~
broken_const.cpp:6:20: warning: 'virtual double TemperatureSource::read_celsius() const' was hidden [-Woverloaded-virtual=]
6 | virtual double read_celsius() const = 0;
| ^~~~~~~~~~~~The warning is useful. It says that the base method is hidden by the new one. The output also has a later error, which the listing above leaves out, saying that bad cannot be created because ForgotConst is still abstract. The fix is to add const to the method in ForgotConst.
Adding methods for a caller that does not exist
This mistake does not produce an error message. The program compiles, and the cost shows up later. A developer adds backup to the interface, writes an empty body in each class, and then forgets that no caller uses it. The example in the section on wide interfaces shows the shape of the problem. Each new class must now implement the method, and each reader must now wonder whether backup does real work. The remedy is the question from the earlier section: which caller needs this, and what does it do with the answer?
Taking the widest interface by habit
Some code accepts the largest interface available, even when a smaller one would do. A function that takes a Storage in place of a LineReader can read, but it also accepts an object that can write, remove and back up. Those extra abilities are not used, and a reviewer cannot tell from the signature which ones matter. Choosing the narrowest interface that works makes the function's signature a short statement of what it needs.
Practice
Exercise 1
Write an interface named Greeter with one pure virtual method, std::string greet(const std::string& name) const. Write a class FriendlyGreeter that returns "Hi, NAME" and a function void say_hello(const Greeter& g) that prints the result of greet("Sam"). Compile with g++ -std=c++20 -Wall.
Show the answer
#include <iostream>
#include <string>
class Greeter {
public:
virtual ~Greeter() = default;
virtual std::string greet(const std::string& name) const = 0;
};
class FriendlyGreeter : public Greeter {
public:
std::string greet(const std::string& name) const override {
return "Hi, " + name;
}
};
void say_hello(const Greeter& g) {
std::cout << g.greet("Sam") << "\n";
}
int main() {
FriendlyGreeter friendly;
say_hello(friendly);
return 0;
}The output is Hi, Sam. The function say_hello depends only on Greeter, so a second greeter class could be passed in without changing it.
Exercise 2
The interface below has four methods. The function print_total only calls total(). Rewrite the interface so that print_total depends only on what it uses. Keep the other methods out of the interface, and explain in one sentence why each one is removed.
#include <iostream>
class Cart {
public:
virtual ~Cart() = default;
virtual double total() const = 0;
virtual void add(double price) = 0;
virtual void clear() = 0;
virtual int item_count() const = 0;
};
void print_total(const Cart& cart) {
std::cout << cart.total() << "\n";
}Show the answer
The interface needs only total. The function print_total takes a parameter of an interface with one method:
#include <iostream>
class Totaled {
public:
virtual ~Totaled() = default;
virtual double total() const = 0;
};
void print_total(const Totaled& cart) {
std::cout << cart.total() << "\n";
}The methods add, clear and item_count are removed from this interface because print_total does not call them. They can live on the concrete Cart class, where the code that adds items already has the full class. A caller that needs to add items takes a Cart& in its own signature. Note that a class such as Cart can still inherit from Totaled and offer the other methods as well.
Exercise 3
A function void save(Storage& s) in the example above only calls s.write(key, value). Change its signature so that it accepts a smaller interface that has only write. Explain what the caller can and cannot do after the change.
Show the answer
Define class Writer with one pure virtual method, virtual void write(const std::string& key, const std::string& value) = 0;, and change the function to void save(Writer& w). Inside the function, call w.write(key, value). The caller can now pass any object that implements write, including a MemoryStorage if it also inherits from Writer. The caller cannot read, remove or list keys inside save, because the function no longer has those methods available. The compiler enforces this. A call such as w.read("a") inside save fails with error: 'class Writer' has no member named 'read'.
Exercise 4
Predict the output of this program, then run it to check.
Show the answer
The output is 10. The reference c has type const Counter&, so the call c.value() goes through the interface. Because value is virtual, C++ runs Twice::value, which returns 5 * 2.
Exercise 5
Explain why the following line fails to compile, using the words "abstract" and "pure virtual" in your answer: Counter c; where Counter is the class from Exercise 4.
Show the answer
Counter has a pure virtual function, value() const = 0, which has no body. A class with a pure virtual function is abstract, and an abstract class cannot be created. The compiler reports error: cannot declare variable 'c' to be of abstract type 'Counter', followed by a note that names Counter::value() const as the pure virtual function. To make the line compile, create a class that implements value, such as Twice, and create that instead.
Summary
- An interface is a promise, and a small interface is easier to implement and to test.
- Start the design from the code that will call the object, and include only the methods that caller uses.
- A wide interface forces every implementation to write methods nobody calls, and forces every caller to depend on them.
- When a caller only reads or only writes, give it a small interface for that job, and let a class implement several small interfaces.
- Use
overrideandconstcorrectly, so that the compiler catches a method that does not match its base.