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); } 1 2 3 4 5 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]; 1 2 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); 1 2 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; 1 2 3 4 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 1 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}; 1 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

Share this post! Don't want to miss out ?