С++ без классов?

от автора

Классы — это скорее всего первое, что добавил Страуструп в далёких 1980х, ознаменовав рождение С++. Если представить, что мы археологи древних плюсов, то косвенным подтверждением этого факта для нас будет this, который по прежнему в С++ является указателем, а значит, скорее всего, он был добавлен до «изобретения» ссылок!

Но речь не про это, пора окинуть взглядом пройденный с тех пор путь, изменение и языка и парадигм, естественный отбор лучших практик, внезапные «великие открытия» и понять к чему это всё привело язык, который когда то вполне официально назывался С с классами (ныне мем).

В конце(СПОЙЛЕР) мы попытаемся превратить С++ в функциональный язык за несколько простых действий

Для начала рассмотрим базовое применение классов:

class Foo : public Bar { // наследование   public:   int x; }; // абсолютно то же самое но struct struct Foo : Bar {   int x; };

Уже на этом простом примере можно заметить, что во времена добавления классов господствовало ООП, инкапсуляция, наследование, всё такое. Поэтому было принято решение, что класс по умолчанию приватно наследуется и поля у него тоже по умолчанию все приватные. Практика показала, что

  • приватное наследование это чрезвычайно редкий зверь, практически не обитающий в реальном коде

  • у вас всегда есть что-то публичное, но не всегда есть что-то приватное

И если изначально сишный struct не обладал возможностями класса по добавлению методов, конструкторов и деструкторов, то на данный момент struct отличается от класса исключительно этими двумя параметрами по умолчанию, а значит каждое использование 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 class! Тут то уж точно никак его не заменить, как отвертеться?

Не поверите, но это работает:

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; // храним лямбду!   // Не наследуемся, потому что это может быть указатель на функцию!   // see below };

Что тут происходит? Есть только одна специализация closure, в которой находится основная логика, каким образом туда попадает type_list с сигнатурой функции и типом мы рассмотрим ниже

Перейдём к основной логике:

Итак, для начала нужно научить лямбду вызываться…

  R operator()(Args... args) {     // static_cast, потому что Args... это независимые     // шаблонные аргументы в этой точке(они уже известны в типе closure)     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 решит эту проблему):

https://godbolt.org/z/E1b3b6j34

Версия с type erasure для удобного рантайм использования здесь в examples:

https://github.com/kelbon/AnyAny


ссылка на оригинал статьи https://habr.com/ru/post/662351/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *