Во многих популярных современных объектно‑ориентированных языках (C#, Kotlin, Swift, Dart) есть такой механизм как extensions methods. Он позволяет добавить к классу необходимые методы и свойства извне, не меняя сам класс. Это очень удобный и полезный механизм, когда, например, мы хотим добавить вспомогательные методы в какой‑нибудь библиотечный класс, которым мы не владеем и не можем его изменить напрямую. Например, добавить к любому стороннему классу свой метод toString().

На примере Kotlin это выглядит очень просто и понятно что происходит в принципе любому, даже не знакомому с Kotlin:

class Person(val name: String)

fun Person.greet() = "Hello $name!"

fun main() {
	val person = Person("John")
	println(person.greet())
}

Ещё одно преимущество расширений - это возможность реализовывать цепочки вызовов в функциональном стиле добавляя необходимые методы не только в свои классы, но и расширяя сторонние. Реальные примеры из жизни это LINQ в C#, Sequences в Kotlin, Streams в Java. Ещё один пример:

listOf(1, 2, 3).map { it.toString() }.joinToString(“,”)

Если это писать в виде обычного вызова функций, то будет уже не так красиво, согласитесь:

joinToString(map(listOf(1,2,3) {it.toString}), “,”)

Мы наблюдаем большое количество вложенности, обилие открывающих и закрывающих скобочек, код читается сложнее, можно запутаться какие параметры в какую функцию передаются.

Во многих динамических языках тоже думают что у них есть методы расширения, но это не совсем правда. Как правило в JavaScript, Ruby и прочих динамических языках мы можем динамически добавить метод в сам класс. Внешне это похоже на методы расширения. Но этот способ довольно опасен. Во первых все остальные пользователи этого класса увидят этот метод, во вторых можно сломать работу класса, если случайно перебить его существующий метод. К примеру, Вася в своей библиотеке добавляет в какой-нибудь системный класс метод toString(), вы, подключаю библиотеку Васи не подозреваете об этом и добавляете свой метод toString() в этот же класс, тем самым ломаете логику библиотеки Васи. Поэтому в динамических языках этот приём расширения классов не распространён и наказуем.

К сожалению, несмотря на ускорившиеся в последние годы темпы развития C++, и добавление кучи полезных фичей, таких как корутины, концепты, расширение стандартной библиотеки, ranges, threads.. механизм расширения почему-то игнорируют, хотя на мой взгляд это довольно простая фича, которая не сильно ломает существующий синтаксис. Даже Страуструп предлагал в 2014 году их добавить, прошло 10 лет, но увы пока мы этого не видим в языке и неизвестно когда увидим.

Отчасти данный подход реализован в библиотеке std::ranges, там есть так называемый pipe синтаксис:

auto const ints = {0, 1, 2, 3, 4, 5};
auto even = [](int i) { return 0 == i % 2; };
auto square = [](int i) { return i * i; };
 
// the "pipe" syntax of composing the views:
for (int i : ints | std::views::filter(even) | std::views::transform(square))
    std::cout << i << ' ';

Он расширяем, вы можете написать свою трансформацию. Я не разбирался с этим, но думаю там не сложно ?

Согласитесь очень похоже на методы расширений.

Для меня есть два небольших «неудобных» момента использования ranges как методов расширения, во первых это есть только начиная с C++20, который ещё далеко не везде завезли, либо завезли, но нужно сохранять совместимость со старыми компиляторами. И второй момент, ranges работают на коллекциях, не на единичных объектах. Кстати, у меня мало с ними опыта, может меня поправят и я изобрёл велосипед.

В общем, надеюсь мне удалось раскрыть тему, можно переходить к практике.

Хочу поделиться своим довольно простым способом реализации методов расширений, при этом повторим pipe синтаксис из библиотеки ranges.

Простейший пример добавления метода toInt() к строке:

#include <iostream>

struct ToIntParams {};

inline ToIntParams toInt() { return {}; }

inline int operator|(const std::string& s, const ToIntParams&) {
	return std::stoi(s);
}

int main() {
	std::cout << ("10" | toInt()) << std::endl;
	return 0;
}

В этом примере мы создаем пустую структуру ToIntParams, и перегружаем оператор “|” для строки и этой структуры, весь нам нужный функционал пишем в перегруженном операторе. Функция toInt() — является вспомогательной, чтобы сократить код. Объект структуры-параметров можно создать и без этой функции.

В следующем примере покажу как добавить реальные параметры методу расширения:

#include <iostream>
#include <sstream>

struct JoinStringParams {
	const char* delimiter;
};

inline JoinStringParams joinToString(const char* delimiter) { return { delimiter }; }

template <typename Iterable>
std::string operator|(const Iterable& iterable, const JoinStringParams& m) {
	std::stringstream ss;
	bool first = true;
	for (const auto& v : iterable) {
    	if (first) first = false; else ss << m.delimiter;
    	ss << v;
	}
	return ss.str();
}

int main() {
	auto intVec = {1, 2, 3};
	std::cout << (intVec | joinToString(",")) << std::endl;
	auto strVec = {"a", "b", "c"};
	std::cout << (strVec | joinToString(",")) << std::endl;
	return 0;
}

Можем написать свой метод трансформации, принимающий на вход функцию трансформации или лямбду:

#include <iostream>
#include <sstream>

template <typename Func>
struct TransformParams {
	const Func& func;
};

template <typename Func>
inline TransformParams<Func> transform(const Func& func) { return { func }; }

template <typename In, typename Func, typename Out = typename std::invoke_result<Func, In>::type>
inline Out operator|(const In& in, const TransformParams<Func>& p) {
	return p.func(in);
}

std::string oddOrEven(int i) {
	return i % 2 == 0 ? "even" : "odd";
}

int main() {
	std::cout << (11 | transform(oddOrEven)) << std::endl;
	return 0;
}

В целом вот и вся идея, теперь различные функции-хелперы вы можете писать в таком стиле, что очень похоже на методы расширения.

Есть правда и недостатки: 

  • нужно больше писать кода для реализации такой функции, 

  • разные IDE не всегда соображают на какую реализацию перейти при нажатии на оператор “|” 

  • непосвящённые программисты могут не понять что происходит, хотя использование выглядит довольно наглядно

  • оператор “|” имеет довольно низкий приоритет и иногда приходится всё выражение брать в скобки, например в случае с использованием оператора << как в моих примерах

Напоследок ещё один пример сериализации моделек данных. Я тут привел пример с неким Node, но можно использовать и что-то из реальной жизни, например QJsonDocument если есть Qt и надо сериализовать в JSON.

#include <iostream>
#include <sstream>
#include <vector>
#include <map>
#include <variant>

// наша модель данных (сервисный слой)
struct Person {
    std::string name;
    int age;
};

struct Organisation {
    std::string name;
    std::vector<Person> stuff;
};

template <typename T>
struct PaginatedResponse {
    int total;
    std::vector<T> items;
};

// Древовидная структура для сериализации
struct Node {
    using Value = std::variant<int, std::string, std::vector<Node>, std::map<std::string, Node>>;
    Value value;
};

// Вывод древовидной структуры в поток
std::ostream& operator<<(std::ostream& s, const Node& n) {
    std::visit([&](auto&& v){ s << v; }, n.value);
    return s;
};

std::ostream& operator<<(std::ostream& s, const std::vector<Node>& vec) {
    s << "[";
    bool first = true;
    for (const auto& v : vec) {
        if (!first) s << ","; else first = false;
        s << v;
    }
    s << "]";
    return s;
};

std::ostream& operator<<(std::ostream& s, const std::map<std::string, Node>& map) {
    s << "{";
    bool first = true;
    for (const auto& v : map) {
        if (!first) s << ","; else first = false;
        s << v.first << "=" << v.second;
    }
    s << "}";
    return s;
};

// функция toString
struct ToNodeParams {};
inline ToNodeParams toNode() { return {}; }

// реализация сериализаторов различных сущностей

Node operator|(int i, const ToNodeParams&) { return {i}; }
Node operator|(const std::string& s, const ToNodeParams&) { return {s}; }

template <typename T>
Node operator|(const std::vector<T>& vec, const ToNodeParams&) {
    std::vector<Node> res;
    for (const auto& v : vec) res.push_back(v | toNode());
    return { res };
}

Node operator|(const Person& p, const ToNodeParams&) {
    std::map<std::string, Node> res;
    res["name"] = { p.name };
    res["age"] = { p.age };
    return { res };
}

Node operator|(const Organisation& o, const ToNodeParams&) {
    std::map<std::string, Node> res;
    res["name"] = { o.name };
    res["stuff"] = { o.stuff | toNode() };
    return { res };
}

template <typename T>
Node operator|(const PaginatedResponse<T>& r, const ToNodeParams&) {
    std::map<std::string, Node> res;
    res["total"] = { r.total };
    res["items"] = { r.items | toNode() };
    return { res };
}

int main() {
    auto response = PaginatedResponse<Organisation> {
        .total = 10,
        .items = {
            Organisation {
                .name = "Acme",
                .stuff = { Person { "John", 30 }, Person { "Sally", 25 } },
            },
            Organisation {
                .name = "Bankrupt"
            },
        }
    };
    
    auto serializedToNodeResponse = response | toNode();
    
    std::cout << serializedToNodeResponse << std::endl;
    
    return 0;
}

Спасибо за внимание.

Комментарии (38)


  1. Apoheliy
    27.09.2024 00:04
    +8

    Насколько понял Ваш основной аргумент в защиту расширений (а также аргументацию из статьи по ссылке): такие выражения:

    x.f(y) и f(x, y) являются идентичными (х - инстанс класса, f - метод, y - некий аргумент). А если идентичны, то давайте делать расширения - ведь это так удобно.

    Тут видится пара шероховатостей:

    Если опираться на саму команду вызова (в ассемблерном коде), то это верно - да, оно идентично.

    Если опираться на конструкции языка C++, то (по-моему) возникают вопросы с типом первого аргумента, передаваемым в функцию f(x, y).

    Это сам инстанс? - А если его нельзя копировать (например, std::mutex)? Или копирование очень "жирное" по ресурсам?

    Это константная ссылка? - И сразу ограничиваемся в вызове методов и доступе к публичным полям (упоминавшийся выше мьютекс - не залочить).

    Обычная ссылка? - Может не получиться из константного объекта.

    Плюс, это всё должно работать в случае x как r-value. И вот как-то это уже "не очень".

    Как результат, утряска всех этих тонкостей может очень дорого обойтись и стандартизаторам, и программистам.

    -

    Почему в других языках это заходит?

    Где-то объектами оперируют только через указатели, где-то аналогов r-value нет.

    Возможно, в этом причина.


    1. Nansch
      27.09.2024 00:04
      +3

      Я вот смотрю на весь этот код и не понимаю, а к чему вся эта графомания в примерах из статьи? Унаследуйте класс, добавьте методов по вкусу и пользуйтесь, не?


      1. a-tk
        27.09.2024 00:04

        Унаследоваться можно далеко не от всего, и далеко не всем можно сказать, что надо использовать унаследованную форму.

        Например, речь о примитивных типах. Или строки, конструирующиеся из строковых литералов.


      1. navrocky Автор
        27.09.2024 00:04
        +1

        Правильно ответили. Наследование это сложнее (из-за конструкторов) и не возможно, когда это библиотечный код. Да и обобщённый код сложнее написать, чтобы был один шаблонный метод и работал с множество разных типов. С шаблонными функциями это всё возможно, но пользоваться неудобно (пример - алгоритмы в C++).


      1. Mingun
        27.09.2024 00:04

        Это сработает, только если вы контролируете место создания объекта. А если вам приходит уже созданный объект?


        1. Nansch
          27.09.2024 00:04

          Мы же говорим про состояние до начала компиляции? Как я могу потерять контроль над местом создания объекта? А если объект приходит уже созданный, то это DLL export чтоли?


          1. navrocky Автор
            27.09.2024 00:04
            +1

            Это значит, что какая-то библиотечная функция уже вернула вам объект, например строку std::string, которую она создала. И тут у вас есть только возможность обернуть возвращённую строку в вашу строку с дополнительным методом.


            1. Nansch
              27.09.2024 00:04

              Вы прикручиваете к экземпляру объекта дополнительный метод, чтобы необычным образом конструировать цепочки вызовов с ним. Ну, свежо и необычно. Но лучше добиться этого классическим синтаксисом - через точку. Пропатчить компилятор?


              1. navrocky Автор
                27.09.2024 00:04

                Ну вот патчить компилятор это вообще последнее дело


    1. navrocky Автор
      27.09.2024 00:04

      Про шероховатости. Не совсем их вижу. Мы же пишем функцию, там явно указываем тип ссылки на объект, *, &. const ... Компилятор только должен её увидеть и попробовать подставить. В приоритете конечно методы объекта, но потом можно пробовать и функции с подходящей сигнатурой. То что творится в шаблонах вас не смущает, SFINAE например, когда компилятор перебирает кучу всего подряд пытаясь найти подходящее?


  1. NeoCode
    27.09.2024 00:04
    +5

    Методы-расширения обобщаются до UFCS, т.е. это должно работать в обе стороны: функция может вызываться как метод от своего первого параметра, и метод может вызываться как функция (статический метод класса) с явной передачей первого параметра. Фича интересная, но почему-то стандартизаторы никак не могут договориться. Хотя казалось бы, всего лишь синтаксический сахар.

    А то что у вас в примере - это эмуляция языковых возможностей. Результат ситуации, когда программисты хотят какую-то возможность, язык ее не предоставляет, но зато язык предоставляет слишком большую свободу при использовании других возможностей. И по моему мнению, лучше таких вещей избегать - это нарушает целостное восприятие самого языка, создает многочисленные "диалекты".


    1. navrocky Автор
      27.09.2024 00:04

      Да, всё верно. Вроде такая простая и полезная фича, а затянуть не могут. Мне не понятно. Зато выкатывают какие-то мудреные странные фичи для каких-то корнер кейсов.

      Я поработал с этим в других языках, начиная с C# в 12 году, мне очень зашло, теперь жить без них не могу.

      Я проэмулировал, оно немного коряво, но на практике пользоваться удобно, написал один раз помучился, но потом кайфуешь. Ну а про диалекты, чтож, в плюсах это на каждом шагу. Кто-то макросы изобретает, кто-то шаблонную магию, которую очень сложно понять. Таков язык. Если в проекте это используется системно, то все привыкают и тоже используют. Да и std::ranges в принципе это то же самое, только понавороченнее.


      1. a-tk
        27.09.2024 00:04

        А Вы посмотрите, как это было сделано в языке D! Вот там это действительно красиво.

        В C# всё-таки немного бойлерплейта надо писать.


        1. navrocky Автор
          27.09.2024 00:04

          Да в C# самая корявая реализация, но это было первое что я увидел в 2012. А так да UFCS то что надо. В Kotlin мне, например, нравится явность определения функции как метода:

          // обычная функция
          fun <T> first(list: List<T>) = list[0]
          
          // метод - расширение
          fun <T> List<T>.first() = this[0]

          Кстати Wikipedia говорит, что

          This has again, in 2023, been proposed by Herb Sutter[13] claiming new information and insights as well as an experimental implementation in the cppfront compiler.

          Возможно мы в каком-то стандарте и увидим такой синтаксический сахар, может и доживём.


          1. a-tk
            27.09.2024 00:04

            А на F# можно сделать любой оператор!

            Пример ниже есть.


  1. Yura_PST
    27.09.2024 00:04
    +1

    Программист читает код, видит:

    std::cout << std::stoi("10") << std::endl;

    И спокойно читает дальше.

    Если же, программист читает код и видит:

    std::cout << ("10" | toInt()) << std::endl;

    То это потребует как минимум 5...10% рабочего времени на понимание того, что здесь происходит. А зачем, вообще лучше не задумываться.

    То есть, это инструкция на тему как украсть рабочее время.


    1. navrocky Автор
      27.09.2024 00:04
      +1

      Ну потратил он один раз 5 минут разобрался и дальше проблем не возникает. Мне этот поход зашел в одном реальном проекте, ranges я не могу использовать потому я ограничен C++17, а цепочечные преобразования очень хочется, так как надоело писать постоянно императивные for. Я наклепал по быстрому различные функции над коллекциями, mapToVector, toSet, toVector, joinToString, reduce и теперь пишу однострочники в стиле ranges:

      auto names = personList | filter([](auto p){return p.age < 30;}) | 
          map([](auto p){return p.name;}) | joinToString(", ");

      Второе где пригодилось это при сериализации моделек данных в Json, мой последний пример. Я сперва пробовал сыграть на перегрузке функции toJson для разных типов, но в итоге столкнулся в шаблонном коде с тем, что компилятор довольно мудрёно ищет подходящие перегрузки и словил кучу проблем с неймспейсами и позицией в коде что первее чего объявлено. Переписал toJson на такой "метод расширения" и всё теперь работает просто и понятно, достаточно перегрузить оператор для нужного типа. Также можно писать конвертеры, например toDto, toModel:

      person | toDto();
      // или
      personDto | toModel();


      1. a-tk
        27.09.2024 00:04
        +2

        А если бы в плюсах не было бы столько приседаний, чтобы писать лямбды...

        Сравните:

        auto names = personList | filter([](auto p){return p.age < 30;}) | 
              map([](auto p){return p.name;}) | joinToString(", ");
        var names = string.Join(", ", personList.Where(p => p.age < 30).Select(p => p.name));
        people
        |> Seq.filter (fun p -> p.age < 30)
        |> Seq.map (fun p -> p.name)
        |> String.concat ", "


        1. navrocky Автор
          27.09.2024 00:04
          +1

          Да в плюсах с лямбдами туго, мне тоже не хватает упрощённого синтаксиса.

          var names = string.Join(", ", personList.Where(p => p.age < 30).Select(p => p.name));

          В С# (кажется это он) ещё не очень лаконично. Вот то же на Котлине, вот где красота:

          val names = personList.filter { it.age < 30 }.map { it.name }.joinToString(",")


          1. qw1
            27.09.2024 00:04

            В c# можно написать generic-расширение
            IEnumerable<T>.joinToString(string delimiter)
            Если хочется оставаться в рамках библиотечных функций, то
            .Aggregate( (x,y) => x + "," + y);


      1. Yura_PST
        27.09.2024 00:04

        Ну потратил он один раз 5 минут разобрался и дальше проблем не возникает.

        Для разбора приведенного примера, а тем более для того чтобы подобные вещи читать бегло, нужно существенно более 5 минут.


  1. gudvinr
    27.09.2024 00:04

    Мы наблюдаем большое количество вложенности, обилие открывающих и закрывающих скобочек, код читается сложнее, можно запутаться какие параметры в какую функцию передаются.

    Звучит похоже на рекламу магазина на диване:


  1. Mingun
    27.09.2024 00:04

    Фактически вы просто использовали другой оператор для того, что уже давно и так известно и широко применяется -- перегрузка операторов << и >>. Это ведь и есть те самые "методы расширения". А умные указатели перегружают operator ->, не уверен, но вроде ничего не мешает перегрузить его как внешнюю функцию и даже будет привычный синтаксис вызова. Но как мне кажется, это сильно усложнит понимание написанного кода.


    1. navrocky Автор
      27.09.2024 00:04

      Фактически вы просто использовали другой оператор для того, что уже давно и так известно и широко применяется -- перегрузка операторов << и >>

      Да, я перегрузил | по аналогии с библиотекой ranges, хотя можно было перегрузить и оператор >>, было бы нагляднее.

      items >> map() >> filter() >> collect()

      К сожалению оператор -> и . нельзя перегрузить вне класса. Да и это бы вносило путаницу с указателями.

      Это ведь и есть те самые "методы расширения".

      Да, но в стандартной библиотеке это применимо только к потокам. Я просто обобщил. Ну и флоу тут "обратный" потокам.


      1. a-tk
        27.09.2024 00:04
        +1

        Зато можно сделать подлянку и перегрузить оператора ,


        1. navrocky Автор
          27.09.2024 00:04

          О даааа =)

          Жалко что нельзя свои операторы определять, вот тогда бы вообще раздолье было бы )

          Кстати в Котлин есть инфиксные функции, наверное и в других каких-то языках есть тоже. Можно делать так:

          infix fun <A, B, C> then(a: A, b: T): C { TODO() }
          
          // и потом писать такое
          1 then "lala" then false


          1. a-tk
            27.09.2024 00:04
            +1

            Пристёгивайтесь крепко!
            module Program
            
            open System
            
            type Person = {
                age: int;
                name: string;
            }
            
            let people : Person list = [
                {age = 32; name = "Jake"};
                {age = 20; name = "Dave"};
                {age = 18; name = "Anna"}
                ]
            
            people
            |> Seq.filter (fun p -> p.age < 30)
            |> Seq.map (fun p -> p.name)
            |> String.concat ", "
            |> Console.WriteLine
            
            
            let inline (|?>) (collection : 'T seq) (predicate : 'T -> bool) =
                collection |> Seq.filter(predicate)
            
            let inline (|=>) (collection : 'T seq) (map: 'T -> 'R) =
                collection |> Seq.map(map)
                
            let inline (<+>) (collection : string seq) (delimiter: string) =
                collection
                |> String.concat delimiter
            
            let inline (|+>) (collection : 'T seq) (delimiter: string) =
                collection
                |> Seq.map (fun x -> x.ToString())
                <+> delimiter
                      
            people
            |?> fun p -> p.age < 30
            |=> fun p -> p.name
            <+> ", "
            |> Console.WriteLine
            


          1. a-tk
            27.09.2024 00:04

            есть инфиксные функции

            В ObjectiveC было такое:

            - (int)add:(int)x to:(int)y;
            
            int result = add: 2 to: 3

            Аргументы идут внутри названия метода!


      1. XViivi
        27.09.2024 00:04
        +1

        что насчёт оператора "->*", кстати? Я глянул: его можно спокойно вне класса перегрузить. И по общей логике подходит. Единственное, не превращать его использование в то, что в первой строчке, ибо приоритет у него чуть ниже, чем у "()", что не совсем интуитивно:

        (obj_ptr ->* ptr) (param) //реальность использования указателей на метод
        obj ->* extention(param) //ожидание или если писать как конвеер

        Не знаю, о чём думали создатели стандарта


        1. navrocky Автор
          27.09.2024 00:04

          О, я даже и не знал что такой оператор есть ) Да, синтаксис вызова неудобный, не подойдёт


  1. alexejisma
    27.09.2024 00:04
    +1

    Библиотека std::ranges основана на библиотеке range-v3, которая работает на C++14 и новее. Поэтому можно эту библиотеку добавить в свой проект. Кроме того, на сколько я помню, в std::ranges вошли не все возможности из range-v3.

    Ссылка: https://github.com/ericniebler/range-v3


    1. navrocky Автор
      27.09.2024 00:04

      Да можно. Но опять таки это только про работу над списками. А хочется применить не только для списков. Надо попробовать старые ranges затащить в мой C++17 проект, вроде сторонних зависимостей нет


  1. yatanai
    27.09.2024 00:04

    Эта фича довольно старая, но применять её надо с осторожностью. Как писали выше, это черевата созданием нового диалекта для конкретно вашей либы а так же нарушением ADL, что иногда плохо заканчивается.

    Использование функций для этого не совсем кошерно, можно использовать constexpr переменные, дабы получить более вкусный синтаксис. Условно (value->as_int) навариваем сюда концептов и получаем забавный способ что-то сделать


    1. navrocky Автор
      27.09.2024 00:04

      Ну я это не в библиотеке использую, а в своём проекте. В библиотеке я, наверное бы, не стал навязывать такие интерфейсы.


    1. navrocky Автор
      27.09.2024 00:04

      Использование функций для этого не совсем кошерно, можно использовать constexpr переменные, дабы получить более вкусный синтаксис. Условно (value->as_int) навариваем сюда концептов и получаем забавный способ что-то сделать

      Я думал о глобальных константах вместо функций. Но есть функции с параметрами, их constexpr переменными не сделаешь.


      1. yatanai
        27.09.2024 00:04

        Смотря какие функции, кейсов довольно много, но чем новее стандарт тем больше позволяется делать в constexpr выражениях.


  1. koloshmet
    27.09.2024 00:04

    > Не разбирался в std::ranges

    А лучше бы разобрался и узнал что через adl можно замечательно перегружать любые имена функций, а не только пайпы и одним лёгким ниеблоидом их диспатчить (см. реализацию std::ranges::begin)


    1. navrocky Автор
      27.09.2024 00:04

      ниеблоидом

      Сперва подумал что это что-то на матерном ) Почитаю про это )

      Но я просто хотел показать как проэмулировать методы расширения. Ranges прикручивать не каждому надо, и заниматься высшей магией с ниеблоидами.