Классы - это скорее всего первое, что добавил Страуструп в далёких 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 решит эту проблему).
Версия с type erasure для удобного рантайм использования здесь в examples.
Комментарии (23)
v1t3man
22.04.2022 00:30+1Если поменять в автомобиле все детали, это будет тот же самый автомобиль?
napa3um
22.04.2022 01:30Важна не конечная точка, а путь. Да, это всё ещё тот же непрерывный путь, обрастающий новыми деталями и избавляющийся от старых :).
OldFisher
22.04.2022 09:12Нет. Это работает с кораблями, Виженом и гномскими топорами, но не с автомобилями.
napa3um
22.04.2022 00:31+4Все языки программирования (со своей эволюцией) нужны только для того, чтобы научить программистов Лиспу. Все там будем :)
hard_sign
22.04.2022 10:31+5Обалдеть. C в своё время был такой лаконичный и понятный язык, а теперь из него сделали какого-то монстра.
Kelbon Автор
22.04.2022 11:08+1Ну, во первых это не С... А что в статье нелаконичное и "монстр"?
hard_sign
22.04.2022 11:22+5Это не C, а его далёкий наследник, да.
А «монстр» – всё. Куча кода, который без статьи совершенно не понятен.
Это хорошо для Perl’а, на котором пишутся «одноразовые» скрипты. Но для продукта, который предполагается развивать и поддерживать, я б такой язык не взял.
Kelbon Автор
22.04.2022 11:29+1Конкретно код в статье по-моему очень даже понятный, если знать синтаксис плюсов
segment
22.04.2022 12:27+6нет, он понятный тем кто над ним работал в течение длительного времени, тогда создается впечатление «очевидности» кода.
koloshmet
24.04.2022 09:03Такой код пишется в недрах библиотек. Обычный человек пишет только код из листинга в конце, который делает очевидно что, потому что он написан на английском (в отличие, например, от хаскеля)
eao197
22.04.2022 13:22+3если знать синтаксис плюсов
Даже если знать синтаксис плюсов, то остается непонятно зачем это все нужно.
firehacker
24.04.2022 05:26-1Проблема в том, что когда вы видите foo+bar в сишном коде, вы знаете, что здесь происходит. Но в cpp-коде то же самое может означать что угодно, за этим может скрываться миллион неочевидных операций. Придётся распутывать клубок наследований, перегрузок.
hobogene
22.04.2022 13:34+1Практика показала, что:
- приватное наследование это чрезвычайно редкий зверь, практически не обитающий в реальном коде;
Это не самая лучшая практика показала-то. Появились Java и иже с ней, где все унаследовано от какого-нибудь Object, и с непубличным наследованием автоматически засада. С оглядкой на это стали писать книжки по OOD (в "Паттернах" написано что-то вроде: "наследование убивает инкапсуляцию", с отсылкой к работе Стайерса от 85-го года, которая на самом деле посвящена тому, как такой ситуации избежать, и которую Страуструп явно читал), по этим книжкам учились люди, которые потом стали писать на C++ ... А в самих плюсах акцент сместился на мета-программирование, что само по себе неплохо, но не является поводом избегать непубличного наследования.
buldo
23.04.2022 01:01Хочу научиться прогать на современном стандарте плюсов, но каждый раз получается С с классами.
Есть какая-нибудь статья, сдвигающая мышление на новый лад?
Blooderino
23.04.2022 21:40-1Ну, можно посмотреть сторонние языки по типу Java или C#, где присутствует жёсткая привязка к ООП и опыт оттуда перенимать в плюсы. Ну и, как минимум, перестать использовать всякие чисто сишные функции и структуры данных и пересесть на плюсовые классы (хотя бы тот же STL). Ну и про всякие принципы SOLID не забывать.
Kelbon Автор
24.04.2022 09:08Из джавы и С# пожалуйста не надо... Эти языки привязались к парадигме слишком сильно и из-за этого кривые
firehacker
24.04.2022 05:16А зачем? У вас цель в решении задачи, в создании программного продукта, или же в абстрактном следовании фен-шую ради следовании фен-шую?
Если решить задачу с применением олдскульных подходов получается быстрее и проще, может это повод задуматься, что не всё так хорошо с новомодными подходами?
Antervis
24.04.2022 14:04вы сами-то в это верите? Или это с позиции человека, который не работал на с++ достаточно, чтобы даже освоить основы с++11?
hobogene
Изначально это все называлось C with classes, так что спору нет. Классы в C++ появились в некотором смысле раньше, чем сам C++ :-)
Археология тут не нужна, достаточно истории, т.е. работы с источниками :-) Это прямо сказано в "Дизайне и эволюции языка C++". Идея почерпнута из Simula-67, где есть ссылка THIS, но ссылок еще не было в языке, добавлены потом, в первую очередь для поддержки перегрузки операторов, ну и появился указатель this.