Классы — это, скорее всего, первое, что добавил Страуструп в далёких 1980-х, ознаменовав рождение С++. Если представить, что мы археологи древних плюсов, то косвенным подтверждением этого факта для нас будет this, который по-прежнему в С++ является указателем, а значит, скорее всего, он был добавлен до "изобретения" ссылок!
Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи — Kelbon.
Но речь не про это. Пора окинуть взглядом пройденный с тех пор путь, изменение и языка, и парадигм, естественный отбор лучших практик, внезапные "великие открытия" и понять, к чему это всё привело язык, который когда-то вполне официально назывался "C с классами" (ныне мем).
В конце (СПОЙЛЕР) мы попытаемся превратить С++ в функциональный язык за несколько простых действий.
Для начала рассмотрим базовое применение классов:
class Foo : public Bar { // наследование
public:
int x;
};
// абсолютно то же самое, но struct
struct Foo : Bar {
int x;
};
Уже на этом простом примере можно заметить, что во времена добавления классов господствовали ООП, инкапсуляция, наследование — всё такое. Поэтому было принято решение, что класс по умолчанию приватно наследуется, и поля у него тоже по умолчанию все приватные. Практика показала, что:
И если изначально сишный struct
не обладал возможностями класса по добавлению методов, конструкторов и деструкторов, то на данный момент struct
отличается от class
исключительно этими двумя параметрами по умолчанию, а значит, каждое использование class
в вашем коде, скорее всего, просто добавляет лишнюю строку. Но добавление struct
всех этих возможностей — лишь первый шаг на пути от классов.
Но ведь у class
есть ещё много значений! Давайте посмотрим на них все!
В шаблоне:
template <class T> // same as template <typename T>
void foo() { }
Пожалуй, единственное применение этой возможности в 2к22 году — это запутывание читателя, хотя некоторые используют ради экономии аж 3-х букв. Не будем судить их.
В шаблоне, но не так бесполезно (для объявления шаблонных шаблонных параметров):
// Функция, которая в качестве шаблонного аргумента
// принимает шаблон с одним аргументом
template <typename <typename> class T>
void foo() { }
// since C++17
template <class <typename> typename T>
void foo() { }
// забавно, но вот так нельзя
template <class <typename> class T> // ошибка компиляции
void foo() { }
В С++17 эта возможность устарела и теперь можно писать typename
без каких-либо проблем. Как видите, мы всё дальше уходим от class
...
Знающие С++ читатели явно вспомнят, что есть же ещё класс enum
! Тут-то уж точно никак его не заменить, как отвертеться?
Не поверите, но это работает:
enum struct Heh { a, b, c, d };
Итого, что мы имеем: на данный момент в С++ нет ни одной реальной необходимости использовать ключевое слово class
, что забавно.
Но ведь это ещё не всё! Слава богам, что С++ не был привязан ни к какой парадигме, и смерть class
практически ничего не меняет. Что же происходило с другими "отраслями" программирования?
В середине девяностых внезапно свершились сразу два великих открытия в плюсовом мире: стандартная библиотека шаблонов (STL) и метапрограммирование на типах.
Оба открытия очень "функциональные". Оказалось, что в STL-алгоритмах гораздо удобнее и гибче использовать шаблоны свободных функций вместо методов. Кроме того, стоит, конечно, выделить begin
/ end
/ size
/ swap
, которые за счёт того, что не являются методами, свободно добавляются сторонним типам и работают в шаблонном коде на фундаментальные массивы, такие как в С.
Метапрограммирование на шаблонах же является чистокровно функциональным, так как там по определению нет глобального состояния и мутабельности, зато есть рекурсия и монады.
Функции и методы тоже кажутся чем-то устаревшим, когда существуют лямбды (функциональные объекты). Ведь, по сути, функция — это функциональный объект без состояния. А метод — это функциональный объект без состояния, принимающий к тому же ссылку на тип, в котором объявлен.
Вот, кажется, мы и подошли к той точке, где накопилось достаточно поводов превратить С++ в функциональный язык... Ну что же, начнём!
Если вдуматься, то всё, чего нам не хватает — замена функциям, методам и каррирование, встроенное в язык, что сравнительно просто реализовать на современном С++.
Возьмём волшебный жезл и мантию метамага:
// всё, что делает этот тип, — хранит остальные типы
template <typename ...>
struct type_list;
// реализацию этого можно найти по ссылке,
// основной функционал — взятие сигнатуры функции по типу
template <typename T>
struct callable_traits;
Теперь, собственно, объявим тип замыкания, которое будет на компиляции хранить любую лямбду и давать необходимые нам операции:
template <typename F>
struct closure;
template <typename R, typename... Args, typename F>
struct closure<aa::type_list<R(Args...), F>> {
F f; // храним лямбду!
// Не наследуемся, потому что это может быть указатель на функцию!
// см. ниже
};
Что тут происходит? Есть только одна специализация closure
, в которой находится основная логика. Каким образом туда попадает type_list
с сигнатурой функции и типом, мы рассмотрим ниже.
Перейдём к основной логике.
Итак, для начала нужно научить лямбду вызываться...
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)...);
}
Ок, это было несложно, добавим же каррирование:
// вспомогательная свободная функция, от которой мы позже избавимся
template <typename Signature, typename T>
auto make_closure(T&& value) {
return closure<type_list<Signature, std::decay_t<T>>>(std::forward<T>(value));
}
// Учимся находить первый тип в паке параметров
// и выдавать "тип-ошибку", если типов 0
template <typename ...Args>
struct first : std::type_identity<std::false_type> {
};
template <typename First, typename ...Args>
struct first<First, Args...> : std::type_identity<First> {
};
// внутри 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...>{});
}
Тут нужно немного больше объяснений... Итак, мы считаем, что если нам дали один аргумент, и функция не вызывается с одним аргументом, то это каррирование. Принимаем мы "реально" тот тип, который в сигнатуре указан первым.
Возвращаем лямбду, которая принимает на один тип меньше и запомнила первый аргумент.
В принципе наша лямбда уже готова. Но остался последний штрих: что, если функция вызывается с одним аргументом? Как её каррировать? И тут на помощь приходит философия.
Что есть каррированная функция с одним аргументом при учёте отсутствия глобального состояния в функциональных языках? Ответ неочевидный, но он прост. Это значение! Любой вызов такой функции просто является значением результирующего типа, и оно всегда одно и то же!
Так что мы можем добавить оператор приведения к результирующему типу, но только для ситуации, когда аргументов 0!
// в closure
operator R()
requires(sizeof...(Args) == 0) {
return (*this)();
}
Стоп! А мы не забыли ничего? Как же пользователь будет пользоваться этим, нужно же указывать тип? С++ об этом позаботился: CTAD (class (heh) template argument deduction) позволяет нам написать подсказку для компилятора, как выводить тип, выглядит она так:
template <typename F>
closure(F&&) -> closure<type_list<
typename callable_traits<F>::func_type, std::decay_t<F>>>;
И наконец мы можем наслаждаться результатом работы:
// Замена глобальным функциям:
#define fn constexpr inline closure
void foo(int x, float y, double z) {
std::cout << x << y << z << '\n';
}
fn Foo = foo; // здесь могла бы быть и лямбда тоже
int main() {
// каррирование
Foo(10, 3.14f, 3.1); // просто вызов
Foo(10)(3.14f, 3.1); // каррирование на 1 аргумент и потом вызов
Foo(10)(3.14f)(3.1); // каррирование до конца
// closure возвращающая 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;
});
};
// Первые 2 аргумента для hmm, вторые 2 для возвращаемой ею closure
hmm(3)(3.f)(5)("Hello world");
// ну и мы поддерживаем шаблонные лямбды/перегруженные функции
// через вот такую вспомогательную функцию
auto x = make_closure<int(int, bool)>([](auto... args) {
(std::cout << ... << args);
return 42;
});
// Что, несомненно, удобно, если вы когда-то пробовали захватить по-другому
// перегруженную функцию
auto overloaded = make_closure<int(float, bool)>(overloaded_foo);
}
Полный код со всеми перегрузками (для производительности) — С++23 deducing this решит эту проблему.
Версия с type erasure
для удобного рантайм использования находится в examples.
Français
101