Once again about class extension methods in C++

In many popular modern object-oriented languages ​​(C#, Kotlin, Swift, Dart) there is such a mechanism as extension methods. It allows you to add the necessary methods and properties to a class from the outside without changing the class itself. This is a very convenient and useful mechanism when, for example, we want to add auxiliary methods to some library class that we do not own and cannot change directly. For example, add your own method to any third-party class toString().

Using Kotlin as an example, it looks very simple and it is clear what is happening in principle to anyone, even those not familiar with Kotlin:

class Person(val name: String)

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

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

Another advantage of extensions is the ability to implement call chains in a functional style by adding the necessary methods not only to your own classes, but also by extending third-party ones. Real life examples are LINQ in C#, Sequences in Kotlin, Streams in Java. Another example:

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

If you write this in the form of a regular function call, then it won’t be so beautiful, you’ll agree:

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

We see a large amount of nesting, an abundance of opening and closing parentheses, the code is more difficult to read, and you can get confused about which parameters are passed to which function.

Many dynamic languages ​​also think that they have extension methods, but this is not entirely true. Typically in JavaScript, Ruby and other dynamic languages, we can dynamically add a method to the class itself. Externally, these are similar to extension methods. But this method is quite dangerous. Firstly, all other users of this class will see this method, and secondly, you can break the work of the class if you accidentally interrupt its existing method. For example, in his library Vasya adds a method to some system class toString()you, I connect Vasya’s library, are not aware of this and add your own method toString() into the same class, thereby breaking the logic of Vasya’s library. Therefore, in dynamic languages ​​this technique of class expansion is not common and is punishable.

Unfortunately, despite the accelerated pace of development of C++ in recent years, and the addition of a bunch of useful features, such as coroutines, concepts, extension of the standard library, ranges, threads… the extension mechanism is somehow ignored, although in my opinion this is a rather simple feature, which doesn't break the existing syntax too much. Even Stroustrup proposed adding them in 2014 (https://habr.com/ru/companies/infopulse/articles/240851/), 10 years have passed, but alas, we don’t see this in the language yet and we don’t know when we will see it.

This approach is partly implemented in the std::ranges library, there is a so-called pipe syntax:

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 << ' ';

It is extensible, you can write your own transformation. I haven’t figured it out, but I think it’s not difficult 🙂

Agree, it is very similar to extension methods.

For me, there are two small “inconvenient” aspects of using ranges as extension methods: firstly, this is only available starting from C++20, which has not yet been imported everywhere, or has been imported, but it is necessary to maintain compatibility with older compilers. And the second point is that ranges work on collections, not on single objects. By the way, I have little experience with them, maybe they will correct me and I have reinvented the wheel.

In general, I hope I managed to cover the topic, we can move on to practice.

I would like to share my rather simple way of implementing extension methods, while repeating the pipe syntax from the ranges library.

The simplest example of adding a method toInt() to the line:

#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;
}

In this example we are creating an empty structure ToIntParamsand overload the operator “|” for the string and this structure, we write all the functionality we need in the overloaded operator. Function toInt() – is auxiliary to shorten the code. A parameter structure object can be created without this function.

In the following example I will show how to add real parameters to an extension method:

#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;
}

We can write our own transformation method that takes a transformation function or lambda as input:

#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;
}

In general, that's the whole idea, now you can write various helper functions in a style that is very similar to extension methods.

There are truths and disadvantages:

  • you need to write more code to implement such a function,

  • different IDEs do not always know which implementation to switch to when clicking on the “ operator|

  • uninitiated programmers may not understand what is happening, although the use looks quite clear

  • operator “|” has a rather low priority and sometimes you have to put the entire expression in brackets, for example in the case of using the operator << as in my examples

Finally, another example of serializing data models. I gave an example here with a certain Node, but you can also use something from real life, for example QJsonDocument if you have Qt and need to serialize it to 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;
}

Thank you for your attention.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *