Во многих популярных современных объектно‑ориентированных языках (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;
}

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

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


  1. Apoheliy
    27.09.2024 00:04

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

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

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

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

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

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

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

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

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

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

    -

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

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

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