Classes were likely the first thing Stroustrup added in the 1980s, marking the birth of C++. If we imagine ourselves as archaeologists studying ancient C++, one piece of indirect evidence supporting the theory would be the 'this' keyword, which is still a pointer in C++, suggesting it was introduced before references!
We published and translated this article with the copyright holder's permission. The author is Kelbon.
That's not the point, though. Let's look back at C++'s evolution since then: the language and its paradigms development, the natural selection of best practices, and occasional "significant discoveries". This will help us understand how the language, once officially called "C with Classes" (now it's more of a meme), has evolved.
At the end of this article (SPOILER), we'll try to turn C++ into a functional language in a few simple steps.
First, we'll look at the basic use of classes:
class Foo : public Bar { // inheritance public: int x; }; // it's exactly the same, but struct struct Foo : Bar { int x; };
Even this simple example shows that OOP, encapsulation, inheritance, and other related concepts were the dominant paradigms when classes were introduced. It was decided that the class would be privately inherited by default, as would its data members. Hands-on experience has shown this:
Originally, the C-style struct
didn't have the capabilities of a class—no member functions, constructors, or destructors. But today, the only difference between a struct
and class
in C++ comes down to these two parameters by default. This means that whenever we use a class
in our code, we're likely adding another extra line. Giving struct
all those capabilities was only the first step away from traditional classes.
But the class
keyword has many more definitions! Let's take a look at them all!
In a template:
template <class T> // same as template <typename T> void foo() { }
Perhaps its only purpose in 2k22 is to confuse the reader, though some use it for the sake of saving as much as three characters. Well, let's not judge them.
In a template, but not as useless (for declaring template template parameters):
// A function that takes a template with // one argument as a template argument template <typename <typename> class T> void foo() { } // since C++17 template <class <typename> typename T> void foo() { } // it's funny, but we shouldn't do that template <class <typename> class T> // compilation error void foo() { }
In C++17, this feature is obsolete, so now we can write typename
without any issues. As you can see, we're moving further and further away from class
...
Readers familiar with C++ obviously remember the enum
class! Since there's no way to replace it, how can we avoid it?
You won't believe this, but the following works:
enum struct Heh { a, b, c, d };
So, this is what we have: at the moment, we don't really need to use the class
keyword in C++, which is funny.
But wait, there's more! Thank goodness C++ isn't tied to any paradigm, so the death of class
changes almost nothing. What was happening with other programming branches?
In the mid-nineties, the C++ world suddenly witnessed two great discoveries: the Standard Template Library (STL) and type metaprogramming.
Both of them were highly functional. They proved to be quite handy: using free function templates instead of member functions in STL algorithms results in greater convenience and flexibility. The begin
, end
, size
, and swap
functions are particularly noteworthy. Since they're not member functions, they can easily be added to third-party types and work with fundamental types, such as C arrays, in template code.
Template metaprogramming is purely functional because it has no global state or mutability, but does have recursion and monads.
Functions and member functions also seem like something obsolete compared to lambdas (functional objects). After all, a function is essentially a functional object without a state. And a member function is a functional object without a state that takes a reference to its declared type.
It seems that we have now accumulated enough reasons to turn C++ into a functional language... All right, let's get started!
If we think about it, all we're missing is a replacement for functions, member functions, and built-in currying, which is relatively easy to implement in modern C++.
Let's wield a magic staff and cloak ourselves in a metamage robe:
// this type only stores other types template <typename ...> struct type_list; // you can find its implementation at the link, // the main feature is to take the function signature by type template <typename T> struct callable_traits;
Now, let's declare the closure type that will store any lambda and provide the necessary operations at compile time:
template <typename F> struct closure; template <typename R, typename... Args, typename F> struct closure<aa::type_list<R(Args...), F>> { F f; // we store the lambda! // We don't inherit here because it might be // a pointer to a function! // see below };
What's going on here? There's only one closure
specialization, which is where the main logic lies. We'll see below how type_list
with the function signature and type gets there.
Let's move on to the main logic.
First, we need to teach the lambda how to be called...
R operator()(Args... args) { // static_cast, because Args... are independent template arguments here // (they're already known in the closure type) return f(static_cast<Args&&>(args)...); }
Okay, that was easy, now let's add some currying:
// an auxiliary free function that we'll remove later on template <typename Signature, typename T> auto make_closure(T&& value) { return closure<type_list<Signature, std::decay_t<T>>>(std::forward<T>(value)); } // We learn to detect the first type in the parameter package // and issue a "type-error" if there are 0 types template <typename ...Args> struct first : std::type_identity<std::false_type> { }; template <typename First, typename ...Args> struct first<First, Args...> : std::type_identity<First> { }; // within closure auto operator()(first_t<Args...> value) requires(sizeof...(Args) > 1) { return [&]<typename Head, typename ...Tail>(type_list<Head, Tail...>) { return make_closure<R(Tail...)>( std::bind_front(*this, static_cast<first_t<Args...>&&>(value)) ); } (type_list<Args...>{}); }
This part requires a little more explanation... So, if we're given one argument, and the function can't be called with just one, we assume it's currying. We "actually" take the type that is specified first in the signature.
We return the lambda that takes one type less and has memorized the first argument.
Our lambda is basically ready now. The final touch remains: what if a function is called with only one argument? How do we curry it? That's where philosophy comes in.
What is a curried function with one argument, given that functional languages lack global state? The answer isn't obvious, but it's simple. The value! Any call to such a function is simply the value of the resulting type, and it's always the same!
So, we can add a cast operator to the resulting type, but only when we have 0 arguments!
// in closure operator R() requires(sizeof...(Args) == 0) { return (*this)(); }
Wait! Aren't we forgetting something? How is the user supposed to use this? They need to specify the type, don't they? C++ has taken care of this! CTAD (class (heh) template argument deduction) enables us to write a hint for a compiler how to deduce a type. Here's what it looks like:
template <typename F> closure(F&&) -> closure<type_list< typename callable_traits<F>::func_type, std::decay_t<F>>>;
We can finally enjoy the result:
// The replacement for global functions: #define fn constexpr inline closure void foo(int x, float y, double z) { std::cout << x << y << z << '\n'; } fn Foo = foo; // the lambda could be here, too int main() { // currying Foo(10, 3.14f, 3.1); // just a normal call Foo(10)(3.14f, 3.1); // currying by one argument and then calling Foo(10)(3.14f)(3.1); // currying up to the end // closure returning closure closure hmm = [](int a, float b) { std::cout << a << '\t' << b; return closure([](int x, const char* str) { std::cout << x << '\t' << str; return 4; }); }; // First two arguments are for hmm, second two are for the closure it returns hmm(3)(3.f)(5)("Hello world"); // we also support template lambdas/overloaded functions // via this auxiliary function auto x = make_closure<int(int, bool)>([](auto... args) { (std::cout << ... << args); return 42; }); // This is certainly useful if you've ever tried to capture // an overloaded function differently auto overloaded = make_closure<int(float, bool)>(overloaded_foo); }
The complete code with all overloads (for performance)—this issue is resolved in C++23 with "deducing this".
A version with type erasure
for convenient runtime use is in examples.
0