Don’t Make Your Interfaces *Deceptively* Simple
Just because we can provide an interface doesn’t mean that we should.
At least this is one of the takeaways that I got from from Howard Hinnant’s opening keynote at Meeting C++ 2019.
In this impressive keynote, Howard made a presentation about <chrono>
and the host of features it brings in C++20. But beyond showing us how to use <chrono>
, Howard explained some of the design rationale of this library.
Those are precious lessons of design, especially coming from someone who had a substantial impact on the design of the standard library. I believe we can apply those practices to our own code when designing interfaces.
So, just because we can provide an interface doesn’t mean that we should. To illustrate what this means in practice, let’s go over two examples in the C++ standard library.
Thanks to Howard Hinnant for reviewing this article.
std::list
doesn’t provide operator[]
Contrary to std::vector
, C++ standard doubly linked list std::list
doesn’t have an operator[]
. Why not?
It’s not because it’s technically impossible. Indeed, here is one possible, even simple, implementation for an operator[]
for std::list
:
template<typename T> typename std::list<T>::reference std::list<T>::operator[](size_t index) { return *std::next(begin(), index); }
But the problem with this code is that providing access to an indexed element in the std::list
would require iterating from begin
all the way down the position of the element. Indeed, the iterators of std::list
are only bidirectional, and not random-access.
std::vector
, on the other hand, provides random-access iterators that can jump anywhere into the collection in constant time.
So even if the following code would look expressive:
auto const myList = getAList(); auto const fifthElement = myList[5];
We can argue that it’s not: it does tell what the code really does. It looks simple, but it is deceptively simple, because it doesn’t suggest that there we’re paying for a lot of iterations under the cover.
If we’d like to get the fifth element of the list, the STL forces us to write this:
auto const myList = getAList(); auto fifthElement = *std::next(begin(myList), 5);
This is less concise, but it shows that it starts from the beginning of the list and iterates all the way to the fifth position.
It is interesting to note that both versions would have similar performance, and despite the first one is simpler, the second one it better. This is maybe not an intuitive thought at first, but when we think about it it makes perfect sense.
Another way to put it is that, even if expressive code relies on abstractions, too much abstraction can be harmful! A good interface has to be at the right level of abstraction.
year_month_day
doesn’t add days
Let’s get to the example taken from the design of <chrono>
and that led us to talk about this topic in the first place.
<chrono>
has several ways to represent a date. The most natural one is perhaps the C++20 long awaited year_month_day
class which, as its name suggests, is a data structure containing a year, a month and a day.
But if you look at the operator+
of year_month_day
you will see that it can add it years and months… but not days!
For example, consider the following date (note by the way the overload of operator/
that is one of the possible ways to create a date):
using std::chrono; using std::literals::chrono_literals; auto const newYearsEve = 31d/December/2019;
Then we can’t add a day to it:
auto const newYearStart = newYearsEve + days{1}; // doesn't compile
(Note that we use days{1}
that represents the duration of one day, and not 1d
that represents the first day of a month)
Does this mean that we can’t add days to a date? Is this an oversight in the library?
Absolutely not! Of course the library allows to add days to dates. But it forces you to make a detour for this, by converting your year_month_date
to sys_days
.
sys_days
sys_days
is the most simple representation of a date: it is the number of days since a certain reference epoch. It is typically January 1st, 1970:
- …
- December 31st, 1969 is -1
- January 1st, 1970 is 0
- January 2nd, 1970 is 1,
- …
- December 31st, 2019 is 18261
- …
sys_days
just wraps this value. Implementing the sum of a sys_days
and a number of days is then trivial.
Adding days to year_month_day
To add a day to a year_month_day
and to get another year_month_day
we need to convert it to sys_days
and then back:
year_month_day const newYearStart = sys_days{newYearsEve} + days{1};
Adding days to a year_month_day
could be easily implemented by wrapping this expression. But this would hide its complexity: adding days to a year_month_day
could roll it into a new month and this requires executing complex calendar calculations to determine this.
On the other hand, it is easy to conceive that converting from year_month_day
and back triggers some calendar-related calculations. The above line of code then makes it clear for the user of the interface where the calculations happen.
On the other hand, providing an operator+
to add days to year_month_day
would be simple, but deceptively simple.
Make your interfaces easy to use correctly and hard to use incorrectly. Make them simple, but not deceptively simple.
You will also like
- Clearer interfaces with optional
- The Expressive Absence of Code
- A Concrete Example of Naming Consistency
- How to Design Function Parameters That Make Interfaces Easier to Use
Share this post!