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 isKelbon.
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:
classFoo:publicBar{// inheritancepublic:intx;};// it's exactly the same, but structstructFoo:Bar{intx;};
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:
- private inheritance is an extremely rare creature, hardly ever found in real-world code;
- you always have something public, but not always something private.
Originally, the C-stylestruct
didn't have the capabilities of a class—no member functions, constructors, or destructors. But today, the only difference between astruct
andclass
in C++ comes down to these two parameters by default. This means that whenever we use aclass
in our code, we're likely adding another extra line. Givingstruct
all those capabilities was only the first step away from traditional classes.
But theclass
keyword has many more definitions! Let's take a look at them all!
In a template:
template<classT>// same as template <typename T>voidfoo(){}
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 argumenttemplate<typename<typename>classT>voidfoo(){}// since C++17template<class<typename>typenameT>voidfoo(){}// it's funny, but we shouldn't do thattemplate<class<typename>classT>// compilation errorvoidfoo(){}
In C++17, this feature is obsolete, so now we can writetypename
without any issues. As you can see, we're moving further and further away fromclass
...
Readers familiar with C++ obviously remember theenum
class! Since there's no way to replace it, how can we avoid it?
You won't believe this, but the following works:
enumstructHeh{a,b,c,d};
So, this is what we have: at the moment, we don't really need to use theclass
keyword in C++, which is funny.
But wait, there's more! Thank goodness C++ isn't tied to any paradigm, so the death ofclass
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. Thebegin
,end
,size
, andswap
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 typestemplate<typename...>structtype_list;// you can find its implementation at the link,// the main feature is to take the function signature by typetemplate<typenameT>structcallable_traits;
Now, let's declare the closure type that will store any lambda and provide the necessary operations at compile time:
template<typenameF>structclosure;template<typenameR,typename...Args,typenameF>structclosure<aa::type_list<R(Args...),F>>{Ff;// 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 oneclosure
specialization, which is where the main logic lies. We'll see below howtype_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...
Roperator()(Args...args){// static_cast, because Args... are independent template arguments here// (they're already known in the closure type)returnf(static_cast<Args&&>(args)...);}
Okay, that was easy, now let's add some currying:
// an auxiliary free function that we'll remove later ontemplate<typenameSignature,typenameT>automake_closure(T&&value){returnclosure<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 typestemplate<typename...Args>structfirst:std::type_identity<std::false_type>{};template<typenameFirst,typename...Args>structfirst<First,Args...>:std::type_identity<First>{};// within closureautooperator()(first_t<Args...>value)requires(sizeof...(Args)>1){return[&]<typenameHead,typename...Tail>(type_list<Head,Tail...>){returnmake_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 closureoperatorR()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<typenameF>closure(F&&)->closure<type_list<typenamecallable_traits<F>::func_type,std::decay_t<F>>>;
We can finally enjoy the result:
// The replacement for global functions:#define fn constexpr inline closurevoidfoo(intx,floaty,doublez){std::cout<<x<<y<<z<<'\n';}fnFoo=foo;// the lambda could be here, toointmain(){// curryingFoo(10,3.14f,3.1);// just a normal callFoo(10)(3.14f,3.1);// currying by one argument and then callingFoo(10)(3.14f)(3.1);// currying up to the end// closure returning closureclosurehmm=[](inta,floatb){std::cout<<a<<'\t'<<b;returnclosure([](intx,constchar*str){std::cout<<x<<'\t'<<str;return4;});};// First two arguments are for hmm, second two are for the closure it returnshmm(3)(3.f)(5)("Hello world");// we also support template lambdas/overloaded functions// via this auxiliary functionautox=make_closure<int(int,bool)>([](auto...args){(std::cout<<...<<args);return42;});// This is certainly useful if you've ever tried to capture// an overloaded function differentlyautooverloaded=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 withtype erasure
for convenient runtime use is inexamples.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse