Small and Large Lambdas
Sometimes lambdas seem like a gift from heaven. They allow the quick generation of STL algorithms without all the headache of setting up the structure of a unary or binary op class, and the ability to create general closures with a single extra character allows not having to worry about specific capturing of members by reference or pointer for use inside the function. In C++14 and following the ability to define a stand-alone lambda with a given name allows for extra clarity when a given operation has to be executed several times but with a decidedly local context. (If I'm repeating the test-and-set pattern of
auto setImplementations = [&](std::string_view inVal) {
if (m_implementations.get() != nullptr)
throw std::runtime_error(
"Only one of -C, -d, -D, or -i allowed as type specification");
m_implementations = m_factory.create(inVal, m_names, m_dataRequirements);
};
four times in a switch statement handling ten possible parameters, it seems a little silly to define the setImplementations() action as a class-level pattern when it is so decidedly local to the function.)
Sometimes, though, they are a siren-song. They're easy, but they hide better design choices.
Short lambdas are (usually) everything you might want. As they extend in size, not only do lambdas local to a function become more complex in themselves, they increase the overall complexity of the containing functions.[1] That facility with simple reference captures, although safe -- definition and execution are in the same context, so there is no issue with data lifetimes -- makes dependencies harder to puzzle out. And sometimes they hide other structural simplifications.
[1]Lambdas may also be local to a class -- you have a std::function member and generate a lambda during the constructor based on the parameters to the constructor. Those lambdas are more purely functional programming, and although the usual concerns about function length may apply, the concerns referenced here take rather different forms, usually having to do with the level of decomposition used in determining what operations should be stored in that way in the first place. That's another topic for another discussion.
Here's a snippet from a function which is generating a two lists of strings which will eventually be single output lines. The take/drop idiom avoids the need to put a test for "first" inside the loop, which was in the code at an even earlier point in time when the logic was being worked out.
auto coreConverter
= [&](const std::string &inRval, const std::string &inInit,
const DataMemberDefinition inVal) -> std::string {
auto [type, name] = inVal;
auto rval = inRval + ConvertTypeForConstructorParameter(type);
rval.append(ToParameterForm(name));
{
std::string printableType(type);
if (IsMovableOnly(type))
printableType.append("&&");
std::string init = inInit + m_converter->convertToMember(name);
init.append(GenerateInitializer(name, printableType));
initializers.push_back(init);
}
return rval;
};
std::ranges::transform(filteredView | std::views::take(1), iter,
[&](const auto &inVal) -> std::string {
return coreConverter(""s, ""s, inVal);
});
std::ranges::transform(filteredView | std::views::drop(1), iter,
[&](const auto &inVal) -> std::string {
return coreConverter(", ", ",\n", inVal);
});
This captures values to the initializers vector and also prints to iter (which is an ostream_iterator), since there is no simple STL algorithm for copying two values to two sinks simultaneously.[2]
[2]It's not that hard to provide an output iterator which takes a pair (or a tuple) and then updates N sinks in different ways when assigned to, but it's probably overkill when the functionality is needed in only one place. This solution is a kind of blend of transform and for_each which avoids needing to iterate over the view another couple of times, twice for the ostream, twice for the vector. It's not entirely pretty, but on balance I consider it acceptable in this context. But it's one reason that CoreConverter, in its final form, is local to an implementation file and not published as a public class in its module.
The lambda has two obvious small issues: first, it's a little long on its own, and, secondly, because it takes three parameters, it needs an additional pair of lambdas to wrap it during the transform calls.
It's hiding a third, but it's completely nonobvious as it stands.
We can pull the logic into a class which captures the init and rval values in the constructor rather than the call, and define the class independently of the function with explicit dependencies for what it captures. The call site will now look like:
CoreConverter conv1(""s, ""s, *m_converter, initializers);
std::ranges::transform(filteredView | std::views::take(1), iter, conv1);
CoreConverter conv2(", "s, ",\n"s, *m_converter, initializers);
std::ranges::transform(filteredView | std::views::drop(1), iter, conv2);
which is not only cleaner but slightly more efficient.
But once we do so, another issue comes into view. Four static functions (ConvertTypeForConstructorParameter(), GenerateInitializer(), ToParameterForm(), IsMovableOnly()) are defined at the class scope of the class of which the calling function is a member. This wasn't obvious originally, because the lambda, as an implementation inside the class, needed no scoping specifications to call them. But once one starts pulling the calls out into a nonmember class, it becomes evident that all of them are ultimately called only in this context -- they were introduced at an earlier stage of making the logic readable.
This means that they can be moved, entirely, from the parent class to the new CoreConverter class, where they become implementation details of that class. This provides a cleaner interface (and implementation) for the parent class.
What was a simple lambda now has an interface that looks like (all implementations skipped):
class CoreConverter
{
public:
CoreConverter(
std::string_view inRval, std::string_view inInit,
const AddClassLib::ConstructorGenerator::IMemberConverter &inConverter,
std::vector<std::string> &inInitializers);
std::string operator()(const AddClassLib::DataMemberDefinition inVal);
private:
std::string m_rval;
std::string m_init;
const AddClassLib::ConstructorGenerator::IMemberConverter &m_converter;
std::vector<std::string> *m_initializers;
std::string ToParameterForm(const std::string &inMember);
std::string GenerateInitializer(std::string_view inVal,
std::string_view inType);
static bool IsMovableOnly(std::string_view inVal);
static bool AppendRefForConstructor(std::string_view inVal);
static std::string
ConvertTypeForConstructorParameter(std::string_view inType);
};
That's a lot more elaborate than the simple lambda, but it's more elaborate because it has attracted what were essentially low-level details the parent class did not need to publish and associated them clearly with the one operation where they are actually used.
Comments
Post a Comment