Making code expressive with lambdas
Lambdas are arguably one of the most noted addition to the language in C++11. It is a useful tool, but one has to make sure to use them correctly to make code more expressive, and not more obscure.
First off, let’s make clear that lambdas do not add functionalities to the language. Everything you can do with a lambda can be done with a functor, albeit with a heavier syntax and more typing.
For instance, here is the comparative example of checking if all elements of a collection of int
s are comprised between two other int
s a and b:
The functor version:
class IsBetween { public: IsBetween(int a, int b) : a_(a), b_(b) {} bool operator()(int x) { return a_ <= x && x <= b_; } private: int a_; int b_; }; bool allBetweenAandB = std::all_of(numbers.begin(), numbers.end(), IsBetween(a, b));
The lambda version:
bool allBetweenAandB = std::all_of(numbers.begin(), numbers.end(), [a,b](int x) { return a <= x && x <= b; });
Clearly the lambda version is more concise and easier to type, which probably explains the hype around the arrival of lambdas in C++.
For such simple treatments as checking if a number is between two bounds, I suppose that many would agree that lambdas are to be preferred. But I want to show that this is not true for all cases.
Beyond typing and concision, the two main differences between lambdas and functors in the previous example are that:
- the lambda doesn’t have a name,
- the lambda doesn’t hide its code from its call site.
But taking code out of the call site by calling a function that has a meaningful name is the elementary technique for managing your levels of abstractions. However the above example is ok because the two expressions:
IsBetween(a, b)
and
[a,b](int x) { return a <= x && x <= b; }
kind of read the same. They are at the same level of abstraction (although it could be argued that the first expression contains less noise).
But when the code gets more into details, the outcome can be very different, as shown in the following example.
Let’s consider the example of a class representing a box, that can be constructed from its measurements along with its material (metal, plastic, wood, etc.), and that gives access to the box’s characteristics:
class Box { public: Box(double length, double width, double height, Material material); double getVolume() const; double getSidesSurface() const; Material getMaterial() const; private: double length_; double width_; double height_; Material material_; };
We have a collection of these boxes:
std::vector<Box> boxes = ....
And we want to select the boxes that would be solid enough to contain a certain product (water, oil, juice, etc.).
With a little bit of physical reasoning, we approximate the strength applied by the product onto the 4 sides of the box as the weight of the product, that is spread over the surfaces of those sides. The box is solid enough if the material can accept the pressure applied on it.
Let’s assume that the material can provide the maximum pressure it can sustain:
class Material { public: double getMaxPressure() const; .... };
And the product provides its density in order to compute its weight:
class Product { public: double getDensity() const; .... };
Now to select the boxes that will be solid enough to hold the Product product, we can write the following code using the STL with lambdas:
std::vector<Box> goodBoxes; std::copy_if(boxes.begin(), boxes.end(), std::back_inserter(goodBoxes), [product](const Box& box) { const double volume = box.getVolume(); const double weight = volume * product.getDensity(); const double sidesSurface = box.getSidesSurface(); const double pressure = weight / sidesSurface; const double maxPressure = box.getMaterial().getMaxPressure(); return pressure <= maxPressure; });
And here would be the equivalent functor definition:
class Resists { public: explicit Resists(const Product& product) : product_(product) {} bool operator()(const Box& box) { const double volume = box.getVolume(); const double weight = volume * product_.getDensity(); const double sidesSurface = box.getSidesSurface(); const double pressure = weight / sidesSurface; const double maxPressure = box.getMaterial().getMaxPressure(); return pressure <= maxPressure; } private: Product product_; };
And in the main code:
std::vector<Box> goodBoxes; std::copy_if(boxes.begin(), boxes.end(), std::back_inserter(goodBoxes), Resists(product));
Although the functor still involves more typing, the line with the algorithm should seem much clearer in the functor case than in the lambda case. And unfortunately for the lambdas version, this line matters more since it is the main code, by which you and other developers start reading to understand what the code does.
Here the lambda has the problem of showing how to perform the box checking, as opposed to just saying that the checking is performed, so it is a level of abstraction too low. And in this example it hurts the readability of the code, because it forces the reader to delve into the lambda’s body to figure out what it does, instead of just saying what it does.
Here, it is necessary to hide the code from the call site, and put a meaningful name on it. The functor does a better job in this regard.
But is it to say that we should not use lambdas in any case that is not trivial?? Surely not.
Lambdas are made to be lighter and more convenient than functors, and you can actually benefit from that, while still keeping levels of abstraction in order. The trick here is to hide the lambda’s code behind a meaningful name by using an intermediary function. Here is how to do it in C++14:
auto resists(const Product& product) { return [product](const Box& box) { const double volume = box.getVolume(); const double weight = volume * product.getDensity(); const double sidesSurface = box.getSidesSurface(); const double pressure = weight / sidesSurface; const double maxPressure = box.getMaterial().getMaxPressure(); return pressure <= maxPressure; }; }
Here the lambda is encapsulated in a function that just creates it and returns it. This function has the effect of hiding the lambda behind a meaningful name.
And here is the main code, alleviated from the implementation burden:
std::vector<Box> goodBoxes; std::copy_if(boxes.begin(), boxes.end(), std::back_inserter(goodBoxes), resists(product));
Let’s now use ranges instead of STL iterators for the rest of this post in order to get a code even more expressive :
auto goodBoxes = boxes | ranges::view::filter(resists(product));
This necessity to hide the implementation becomes all the more important when there is other code surrounding the call to the algorithm. To illustrate this, let’s add the requirement that the boxes must be initialized from textual descriptions of measurements separated by commas (e.g. “16,12.2,5”) and a unique material for all the boxes.
If we use direct calls to on-the-fly lambdas, the result would look like this:
auto goodBoxes = boxesDescriptions | ranges::view::transform([material](std::string const& textualDescription) { std::vector<std::string> strSizes; boost::split(strSizes, textualDescription, [](char c){ return c == ','; }); const auto sizes = strSizes | ranges::view::transform([](const std::string& s) {return std::stod(s); }); if (sizes.size() != 3) throw InvalidBoxDescription(textualDescription); return Box(sizes[0], sizes[1], sizes[2], material); }) | ranges::view::filter([product](Box const& box) { const double volume = box.getVolume(); const double weight = volume * product.getDensity(); const double sidesSurface = box.getSidesSurface(); const double pressure = weight / sidesSurface; const double maxPressure = box.getMaterial().getMaxPressure(); return pressure <= maxPressure; });
which becomes really difficult to read.
But by using the intermediary function to encapsulate the lambdas, the code would become:
auto goodBoxes = textualDescriptions | ranges::view::transform(createBox(material)) | ranges::view::filter(resists(product));
which is — in my humble opinion — what you want your code to look like.
Note that this technique works in C++14 but not quite in C++11 where a small change is needed.
The type of the lambda is not specified by the standard and is left to the implmentation of your compiler. Here the auto
as a return type lets the compiler write the return type of the function to be the type of the lambda. In C++11 though you can’t do that, so you need to specify some return type. Lambdas are implicitly convertible to std::function
with the right type parameters, and those can be used in STL and range algorithms. Note that, as righlty pointed out by Antoine in the comments section, std::function
incurs an additional cost related to heap allocation and virtual call indirection.
In C++11 the proposed code for the resists
function would be:
std::function<bool(const Box&)> resists(const Product& product) { return [product](const Box& box) { const double volume = box.getVolume(); const double weight = volume * product.getDensity(); const double sidesSurface = box.getSidesSurface(); const double pressure = weight / sidesSurface; const double maxPressure = box.getMaterial().getMaxPressure(); return pressure <= maxPressure; }; }
Note that in both the C++11 and C++14 implementation there may not be any copy of the lambda returned by the resists
function, as the return value optimization will likely optimize it away. Note also that functions returning auto must have their definition visible from their call site. So this technique works best for lambdas defined in the same file as the calling code.
Conclusion
In conclusion:
- use anonymous lambdas defined at their call site for functions that are transparent for the level of abstraction
- otherwise, encapsulate your lambda in an intermediary function.
Related articles:
- Super expressive code by raising levels of abstraction
- Ranges: the STL to the Next Level
- Return Value Optimizations
Share this post!