Should we stop writing functions?

... and use lambdas instead?

That is, instead of:

int sum(int a, int b) {
    return a + b;
}
A function

You'd write:

constexpr auto sum = [](int a, int b) -> int {
    return a + b;
};
A named lambda

Hear me out.

Lambdas don't allow ADL

(Unintentional use of) Argument-dependent lookup is considered a mistake. For example, unqualified calls in generic code may call arbitrary functions found in one of the associated namespaces of their arguments, and not the one you intended to call in your own namespace!

namespace my {
    template <typename T>
    void destroy(T* elem) {
        elem->~T();
        delete elem;
    }

    template <typename Rng>
    void destroy_range(Rng&& rng) {
        for (auto&& elem : rng)
          destroy(elem); // unintentional ADL!
    }
}
An unqualified call that can trigger ADL, if the user provides a more specialized overload. Click the play button for an example.

As such, you need to qualify all calls in generic code!

At think-cell, we take special care to prevent unintentional ADL by forcing users to qualify all functions with tc::. This is done by moving all type definitions into a separate namespace:

namespace tc {
    namespace no_adl {
        struct foo {};
			
        // no functions here
    }
    using no_adl::foo;

    void use_foo(foo f);
}
ADL barriers

ADL will only consider functions in the namespace tc::no_adl, but all functions are in tc directly. So you can't call use_foo via ADL; you have to qualify it.

However, if we only use named lambdas, we completely avoid the issue: constexpr variables are not found via ADL.

namespace tc {
    struct foo {};
	
    constexpr auto use_foo = [](foo f) { … };
}
Lambdas make an ADL barrier unnecessary

Lambdas don't allow overloading

Regular functions can be overloaded, but lambdas cannot. So instead of function overloads, we'd need to use something different. This is actually an advantage!

There are two kinds of overload sets: closed and open. A closed overload set is one where the author of one overloaded function knows all other overloads. That is, there is a set of N types we want to support in a slightly different way. This can be accomplished trivially using the "overloaded" trick:

constexpr auto foo = tc::make_overload(
    [](int i) { … },
    [](float f) { … },
    [](std::string const& str) { … }
);
The "overloaded" trick allows a closed lambda overload set

Alternatively, you can write a single generic lambda that uses if constexpr and/or requires to dispatch between types:

constexpr auto foo = []<typename T>(T const& arg) {
    if constexpr (std::same_as<T, int>) {
        …
    } else if constexpr (std::same_as<T, float>) {
        …
    } else if constexpr (requires (T const& arg) { arg.c_str(); }) {
        …
    } else {
        static_assert(error<T>, "no matching overload for T");
    }
};
Manually implement overload resolution

I find the second approach a lot cleaner than an overload set anyway: You have full control over the priority and conversions of the overloads, don't need to keep complicated resolution rules in mind, and can give better error messages if something does not match.

The second kind of overload set is an open one. Here we want to write a customization point: Users can overload the function for their own types. This is also the only use case of ADL.

namespace std {
    template <typename T>
    void swap(T& lhs, T& rhs); // default

    template <typename T>
    void swap(std::vector<T>& lhs, std::vector<T>& rhs); // customization
}

namespace other {
    void swap(foo& lhs, foo& rhs); // customization
}

namespace my {
    template <typename T>
    void use(T& arg) {
        …
        using std::swap;  // enable default
        swap(arg, other); // allow ADL
        …
    }
}
The classical ADL-based customization point idiom

However, the use is always a bit awkward since you have to explicitly bring in the default version from the namespace. It is much better if you split it into two functions: one that is customizable via ADL, and one that just calls the ADL one. It can look something like this:

namespace std {
    template <typename T>
    void swap_impl(T& lhs, T& rhs); // default

    template <typename T>
        requires requires(T& lhs, T& rhs) { swap_impl(lhs, rhs); }
    void swap(T& lhs, T& rhs)  { // interface
        swap_impl(lhs, rhs); // enable ADL
    }

    template <typename T>
    void swap_impl(std::vector<T>& lhs, std::vector<T>& rhs); // customization
}

namespace other {
    void swap_impl(foo& lhs, foo& rhs); // customization
}

namespace my {
    template <typename T>
    void use(T& arg) {
        …
        std::swap(arg, other); // call interface
        …
    }
}
An improved ADL-based customization point idiom

(With a bit more work, you can also re-use the name "swap" for the specialization function.)

Now a user can just call the qualified version and it will internally dispatch accordingly. Crucially, swap is no longer an overload set and can be written as a single lambda—only swap_impl needs to be a function because we explicitly want ADL.

So the lack of overload support for lambdas isn't actually an issue. In fact, we are forced to use a more expressive way of writing overloads. And the benefit is that we can pass an entire overload set as a single object to other functions! With plain functions, we would need to use the "overloaded" idiom directly or use a macro like our tc_fn, which lifts an overload set into a lambda.

Lambdas give you control over template parameters

Consider a function like std::make_unique:

template <typename T, typename ... Args>
std::unique_ptr<T> make_unique(Args&&... args);
std::make_unique as a function

It has two kinds of template parameters: First, T which you need to explicitly specify in the call site. Second, Args which is deduced by the compiler. It is an error if you don't specify T as it cannot be inferred, and it is a bad idea if you specify Args, as you probably get the types slightly wrong.

I've never liked that there was no distinction between the two; both are just regular template parameters. With the use of named lambdas, we are forced to make the distinction explicit. Inferred template parameters are just template parameters or auto parameters of the lambda, but explicit template parameter turn our constexpr variable into a constexpr template:

template <typename T>
constexpr auto make_unique = []<typename ... Args>(Args&&... args) {
    …
};
std::make_unique as a named lambda

We are still forced to specify T but can no longer specify Args (unless you do something like make_unique<int>.operator()<int>(0), but who does that?!). This is a nice distinction to have.

Furthermore, we can freely pass make_unique<int> around as a single callable object, which takes arbitrary arguments and returns a std::unique_ptr<int>.

Lambdas are implicitly constexpr

Unlike regular functions, a compiler will implicitly make the call operator of a lambda constexpr. There's no need to think about it and explicitly mark your function as constexpr, it'll just be constexpr.

Need I say more?

Note that the initial constexpr in the examples refers to the variable that holds the lambda object, not the call operator. Since the lambdas don't capture anything, they can always be constructed at compile-time, regardless of the code in the lambda body.

Conclusion

All in all, abandoning functions in favor of named lambdas has advantages:

  • They aren't found via ADL.
  • They are single objects, not overload sets.
  • They allow a distinction between implicit and explicit template parameters.
  • They are implicitly constexpr.

Of course, there are downsides:

  • A lambda cannot be forward-declared and has to be defined in the header. This is a non-issue for generic lambdas, and the use of modules limit the compilation time impacts. Still, this means that indirect recursion may not be expressible using that idiom directly.
  • The symbol names of functions becomes ugly: It is now a call operator of some lambda with a compiler synthesized name, and no longer a named function.
  • It's weird.

Still, the standard library has started using that idiom: the range customization points like std::ranges::size are function objects that use ADL internally, the algorithms in std::ranges are all function objects as well, etc.

This means that compiler writers are incentivized to tackle the engineering problems.

So maybe we should just stop writing functions?

(This is sad from a language design perspective, but that's C++ I guess.)

— by Jonathan Müller

Do you have feedback? Send us a message at devblog@think-cell.com !

Sign up for blog updates

Don't miss out on new posts! Sign up to receive a notification whenever we publish a new article.

Just submit your email address below. Be assured that we will not forward your email address to any third party.

Please refer to our privacy policy on how we protect your personal data.

Share