writing_great_libraries_cppcon_2015



writing_great_libraries_cppcon_2015

0 5


writing_great_libraries_cppcon_2015

Talk and related materials for my "Writing Great Libraries" talk at CppCon 2015.

On Github tzlaine / writing_great_libraries_cppcon_2015

Writing Great Libraries

Zach Laine

Outline

  • Motivation
  • Efficiency
  • Usability
  • Implementation Tips

Part 1

Motivation

Let's start from first principles.

Why do we write libraries?

Code reuse of course!

This is the guiding principle that should drive all our work as library writers.

What makes library code different from application code?

We don't know all the requirements for use at the time we write the code.

Q: How much time can I spend in handle_click() and still be efficient enough?

void handle_click()
{
    // ...
}
button_t button;
button.connect(&handle_click);

A: A lot! I have about 100 milliseconds to do whatever I want, because the user will never notice any latency less than that after a click.

Q: What about here?

namespace my_lib {
    void foo ()
    {
        // ...
    }
}

A: I have no idea

... and neither do you.

We must make a library as usable as possible to anyone that might want to use it (and no more).

That implies efficiency is our goal, since we're working in C++, and maximum efficiency, since we don't know the usage context.

It also implies maximal usability, for the same reason.

There are two audiences you are writing for when you write code: human readers and the compiler. You have to make your code easy to reason about for both audiences as a library writer.

To Summarize

Principle 0: We write libraries for code reuse.

Principle 1: Libraries must be maximally efficient (see Principle 0).

Principle 2: Libraries must be maximally usable (see Principle 0).

Part 2

Efficiency

We all know lots of guidelines for making C++ efficient:

Make your moves noexcept.

Minimize copies.

Minimize resource allocation.

Etc....

Those are all implementation guidelines.

We must derive interface guidelines from such implementation guidelines.

How do we do this, when we don't know the usage context?

An Example: Getting a sequence of things

A bad way:

struct X { /*...*/ };

int num_xs ();

const X * get_x (int i);
const int num = num_xs();
for (int i = 0; i < num; ++i) {
    const X * x = get_x(i);
    use(x);
}

This interface requires:

  • Checking of the parameter to get_x() in library code (O(N) branches).
  • Checking the result of get_x() in user code.
  • O(N) function calls.

We would like:

  • Minimal branches
  • Minimal function calls

The C way:

int num_xs ();

void get_xs (X** xs);
const int num = num_xs();
std::vector<X*> xs(num);
get_xs(&xs[0]);
for (auto x : xs) {
    use(x);
}

A bit better. We only need to call two API functions, and there are no more per-element branches.

However, do we really need to allocate memory? Maybe the user just needs to see each element, not copy it.

Also, what happens if the sequence has an unknown size?

Better would be a lazy range:

struct x_range {
    // iterator into our internal storage
    using iterator =
        std::vector<X>::const_iterator;
    iterator first, last;
};

x_range::iterator begin (x_range r)
{ return r.first; }

x_range::iterator end (x_range r)
{ return r.last; }

x_range get_xs ();
x_range range = get_xs();
for (auto const & x : range) {
    use(x);
}

// This will use memcpy for PODs, etc.
std::vector<X> copy_of_xs(size(range));
std::copy(begin(range),
          end(range),
          copy_of_xs.begin());

This still doesn't handle unbounded sequences, but it could if you changed the iterators to input iterators.

Efficiency Guidelines

Your API needs to be efficient, under all reasonably forseeable usage scenarios.

To get there, constantly ask "what if I used the API like this ...".

Don't get crazy.

Part 3

Usability

Names Are Important

Don't give your interfaces unintuitive names!

An Example: Selecting from a Sequence

struct X
{
    int value;
    /*...*/
};
std::vector<X> xs = { /*...*/ };
std::vector<X> selected_xs;

filter(
    xs.begin(),
    xs.end(),
    std::back_inserter(selected_xs),
    [](X const & x) { return x.value == 42; }
);

What does this do?

Does it filter in or filter out?

std::copy_if(
    xs.begin(),
    xs.end(),
    std::back_inserter(selected_xs),
    [](X const & x) { return x.value == 42; }
);

What does this do?

Everyone here knows what this does.

Unclear (nonobvious) names make code hard to reason about, and thus decrease productivity.

The Principle of Least Surprise

Don't make your interfaces do unexpectable things.

An Example:

struct X
{ /*...*/ };

void RegisterX(X const * x);
X * x = new X{ /*...*/ };
RegisterX(x);

This looks pretty clear, right?

void RegisterX(X const * x)
{
    if (x) {
        // Some stuff that "registers" x...
    }
    delete x; // WTF
}

How was I supposed to know this would happen?

That name is so misleading!

Names are important.

Make interfaces impossible to use incorrectly

If that proves impossible, make interfaces difficult to use incorrectly.

An example:

What happens when we try to restrict the number of X's we want?

int num = num_xs();
// some code to here change num,
// in order to take a subset of num_xs() ...
for (int i = 0; i < num; ++i) {
    const X * x = get_x(i);
    use(x);
}

We might introduce an indexing error!

If num_xs() < num and we do not check that x != nullptr, we get UB (and a crash if we're lucky).

We are less likely to get errors of this kind using the range-based approach, because we are not allowed to directly index into the range.

If we change the iterator category of the range to InputIterator, the indexing errors become impossible.

Avoid Aliasing

Aliasing confuses you, your users, and the compiler.

An example:

void xor_swap (int & a, int & b)
{
    a ^= b;
    b ^= a;
    a ^= b;
}

Why is this wrong?

void xor_swap (int & a, int & b)
{
    if (&a != &b) {
        a ^= b;
        b ^= a;
        a ^= b;
    }
}

Why is this bad?

The extra branch is certainly bad.

Having to consider such things as identity is pretty bad too.

void xor_swap (int & a_, int & b_)
{
    int a = a_, b = b_;

    a ^= b;
    b ^= a;
    a ^= b;

    a_ = a; b_ = b;
}

This is better.

struct int_pair
{
    int a, b;
};

int_pair xor_swap (int a, int b)
{
    a ^= b;
    b ^= a;
    a ^= b;
    return int_pair{a, b};
}

So is this.

As a guideline, avoid pointers and references wherever possible, to avoid your own difficulty in understanding your implementation.

Sometimes references are unavoidable.

Look at the code your compiler generates to determine if this is really the case.

Another example:

struct Base { /*...*/ };

struct S : Base { /*...*/ };

unsigned int * get_index (const Base & b);

std::pair<S *, unsigned int *>
new_s_with_index ();

As a user of new_s_with_index() who does not have access to the implementation, what are your questions about how it behaves?

Possible implementation:

std::pair<S *, unsigned int *>
new_s_with_index ()
{
    std::pair<S *, unsigned int *>
        retval{new S, nullptr};
    retval.second =
        get_index(*retval.first);
    return retval;
}

Does get_index() throw?

If the answer to 1 is true, does that mean that new_s_with_index() throws too?

If the answer to 2 is false, does that mean that new_s_with_index() swallows all exceptions produced by get_index()?

How about this instead?

struct Base { /*...*/ };

struct S : Base { /*...*/ };

unsigned int * get_index (const Base & b);

// We could also use std::unique_ptr<S> here.
std::pair<S, unsigned int *>
new_s_with_index ();

I no longer have any of those questions from before. From the interface alone, I know the answers:

Whether get_index() throws is largely moot -- see below.

If get_index() throws, so does new_s_with_index().

Whatever get_index() throws, new_s_with_index() throws the same thing.

Don't use pointers to indicate availability of a value.

As another guideline, keep (raw and shared-) pointers and references out of your APIs. They make your code harder to reason about.

Raw and shared pointers are shared state. They have all the same problems as global state.

To recap, we just covered:

  • Names Are Important
  • The Principle of Least Surprise
  • Make interfaces impossible to use incorrectly
  • Avoid Aliasing

What is the common through-line in all of those?

Your code must be easy to reason about to be usable.

These are not actually separate topics, but different aspects of the same principle.

Part 3.5

Composability

This is another aspect of usability of course.

And now, some assertions.

Types should be regular

Regular means:

  • Default constructible
  • Copyable
  • Assignable
  • Movable
  • Destructible
  • Equality-comparable

Copy and assignment must not create aliases for a type to be regular.

Semiregular is often useful too.

Think of "Regular" as "like a number".

Numbers are way easier to reason about than most C++ classes.

Better yet, think of "Regular" as "like a banana".

Bananas are even easier to reason about than numbers.

Functions should be pure

Pure functions have no side effects (and therefore no aliasing).

Remember our friend xor_swap().

What are your concerns about this function call?

int i = 2;
auto result = bar(foo(i), foo(i));

There are no concerns if foo() and bar() are pure.

Types should define a basis

A member function basis is the minimal set of functions required to efficiently maintain a type's invariants.

A type should not include other member functions that are not part of its basis.

Why don't we have std::vector::sort()?

Because that would be stupid.

We don't want a different sort() for each container type, right?

Don't add things to your types' bases that can be done in terms of the existing basis.

This will allow you to generalize (and often force you to think about generalizing).

See std::string for a counterexample of defining a basis.

Container types should be interoperable with STL algorithms.

If this requires custom iterators, get to know the Boost.Iterator library.

#include <boost/iterator/iterator_facade.hpp>

struct node_base { /*...*/ };

struct node_iter :
    public boost::iterator_facade<
        node_iter,
        node_base,
        boost::forward_traversal_tag
    >
{
    node_iter();
    explicit node_iter(node_base * p);

 private:
    friend
        class boost::iterator_core_access;

    void increment();
    bool equal(node_iter const & it) const;
    node_base & dereference() const;

    node_base * m_node;
};

Container types should be usable in range-based for loops.

This takes surprisingly little effort.

struct custom_array
{
    std::array<int, 5> m_storage;
};

auto begin (custom_array const & a) -> decltype(a.m_storage.begin())
{ return a.m_storage.begin(); }

auto end (custom_array const & a) -> decltype(a.m_storage.end())
{ return a.m_storage.end(); }


custom_array array;
for (auto value : array) {
    // whatever
}

Library types should be usable in a container or a subobject.

If I can't use your type in a std::vector, your type is not very reusable.

Regularity helps a lot with this.

Library types should be constructible anywhere (heap, stack, static storage, constant expressions) whenever possible.

A type that downloads data from the internet in its constructor is not ROM-able.

Error messages (even compiler error messages) must be usable, or your library is not.

See Boost.Graph or Boost.Spirit for hard-to-read error messages, due to deep template instantiation depths.

Come up with a good convention for reporting compilation errors to the user.

A Note About the Previous Use of "Should"

Always reach first for the kind of client code you want to read, write, and reason about.

You are not wrong to use non-regular types, impure functions, etc.

Get as close to that as you can. Diverge from that only if you have to.

Part 4

Library Implementation Tips

Implementation Is Design

There is no difference to a programmer.

Use your code as you go, and refine ("sand off rough corners").

This is an iterative endeavor.

Writing tests as you go can really help with this.

Consider starting with the client code you want, long before it compiles, and trying to implement the library around that.

Think of this process as discovering correct solutions, not inventing them; familiarity with other libraries helps.

If you try something and it doesn't work or has notable warts, keep searching.

An important metric in this process: Make simple things easy to do, and complex things possible to do.

An Example:

A linear algebra library.

matrix_t saxpy (float a, matrix_t const & x, matrix_t const & y);
auto z = saxpy(a, x, y); // Wha?
matrix_t operator* (float a, matrix_t const & x);
matrix_t operator+ (matrix_t const & x, matrix_t const & y);
auto z = a * x + y; // Now this I get.

We probably need to use expression templates to get rid of the temporaries again.

Documentation is important to users; without proper docs, many useful libraries would not be.

Docs are also very, very, useful to the design process.

Anticipate future needs.

Like thread safety.

Like versioning.

Don't get crazy with the features.

Header-only vs. Compiled

Header-only libraries are potentially usable in minutes, which makes them compelling.

Compiled libraries don't require recompilation. This makes them compelling for a different reason.

If these issues are very important to you or your users, try the ".ipp" pattern.

// foo.h
namespace foo {
    int bar ();
}

#if FOO_HEADER_ONLY
#include "foo.ipp"
#endif
// foo.cpp
#if ! FOO_HEADER_ONLY
#include "foo.ipp"
#endif
// foo.ipp
namespace foo {
    int bar ()
    { return 42; }
}

Understand how inline namespaces work, and use them.

Generic Code

All templates are constrained by concepts, even if only implicitly, without the concept language feature.

If you see this in the documentation:

template <typename T>
void some_function (T a, T b);

That leaves out the requirement that T be defined for operator+():

template <typename T>
void some_function (T a, T b)
{
    auto c = a + b;
    print(c);
}
template<typename T>
concept bool Arithmetic() {
    return requires(T x, T y) {
        {x + y} -> T;
        // ...
    }
}

template <Arithmetic T>
void some_function (T a, T b);

If you have the concepts language feature, use it liberally.

If you do not, you must document the constraints.

To Summarize

Motivation

Principle 0: We write libraries for code reuse.

Principle 1: Libraries must be maximally efficient.

Principle 2: Libraries must be maximally usable.

Practice

The Principle: Your library API must be easy to reason about.

Guideline 1: Names are important.

Guideline 2: Don't violate The Principle of Least Surprise.

Guideline 3: Make interfaces impossible to use incorrectly.

Guideline 4: Avoid aliasing.

Guideline 5: Types should be regular, functions pure, and types should define a basis and no more.

Guideline 6: Container types should be interoperable with STL algorithms, and usable in range-based for loops.

Guideline 7: Types should be usable in a container or subobject.

Guideline 8: Types should be constructible anywhere.

Guideline 9: Error messages must not impede use of your library.

Guideline 10: Don't slavishly follow these guidelines.

Questions?

0