Comments 106
А, ну да. В нем всё ещё нет нормальных дженериков — только шаблоны а-ля банальный текстовый препроцессор.
Да и все что описано в данное статье не столь изящно, но решается обычными шаблонами. Без условно концепции позволят делать все это куда более понятно и короче, так что я «за».
И дженериками я столкнулся только однажды — при изучении Scala и ловил себя на мысле, что мне их мало. Основной неожиданностью для меня было, что дженерики в Scala не позволяют вызвать произвольный методы, что доступно при работе с шаблонами в C++. Задачи решить можно было, но это требовало задействование других механизмов языка. А если при использовании дженериков воспользоваться указанием, что он должен быть наследником какого-то класса, то тогда уже можно и без них обойтись.
Из основных плюсов — их можно экспортировать, но за счет этого они получаются очень ограниченными.
Макросы в C/C++ позволяют делать довольно хитрые и удобные вещи, только они все таки в стороне от шаблонов и дженериков. Вот они все таки ближе к определению текстовый процессор, чем шаблоны.
use std::error::Error;
fn print_error<T: Error>(value: T) {
unimplemented!();
}
fn main() {
print_error(10_i32);
}
То у вас будет понятное сообщение об ошибке:
Compiling playground v0.0.1 (file:///playground)
error[E0277]: the trait bound `i32: std::error::Error` is not satisfied
--> src/main.rs:8:5
|
8 | print_error(10_i32);
| ^^^^^^^^^^^ the trait `std::error::Error` is not implemented for `i32`
|
= note: required by `print_error`
А не мешанина из кишков темплейта.
Не говоря про возможность собирать дженерик из типов динамически в рантайме.
Про «лучшесть» или «хужесть» дженериков в целом судить не берусь, но лично мне ими пользоваться удобнее.
возможность собирать дженерик из типов динамически в рантайме.И невозможность собирать тип в компайл-тайме?
генерация имплементации интерфейса, который прилетает по сетиЭто больше про рефлекшн вообще
Если же некоторый язык компилируется сразу в машинный код — не вижу принципиальных проблем собирать все сразу при компиляции.
в языках использующих JIT в конечном счете все равно код будет в рантайме генерироваться.Ну, например, компиляторы C++/CLI или C# (unsafe), вполне могут полагаться на JIT, так и генерировать нативный код.
Компилятор C# не имеет генерировать нативный код в принципеДа, согласен, тут заблуждался.
C++/CLI генериком может быть только управляемый класс…Обратного не утверждалось. Однако как рантаймовые дженерики, так и компайлтаймовые шаблоны там есть, хотя вы их разделили лишь по признаку наличия JIT.
Нет, я их разделил по свойствам.
template в C++/CLI:
- может работать с любым кодом;
- может быть специализирован;
- умеет SFINAE;
- может обращаться к любым членам типа-параметра;
- не может экспортироваться за пределы модуля (экспортироваться могут только его инстансы).
generic при этом:
- может работать только с управляемым кодом;
- не может быть специализирован;
- не умеет делать трюки вроде SFINAE;
- не умеет обращаться к произвольным членам типа-параметра;
- может быть экспортирован за пределы модуля и даже сборки.
А вот взаимодействие можно было бы построить через рефлекшн (опционально сохранять метаинформацию о шаблонах и позволять им работать как дженерики в рантайме).
Ничто не мешает сделать шаблоны поверх JIT-компилируемого кода.
Более того, в языке они есть.
А вот взаимодействие можно было бы построить через рефлекшн (опционально сохранять метаинформацию о шаблонах и позволять им работать как дженерики в рантайме).
Нельзя так просто взять и сделать из произвольного шаблона дженерик.
Печально, когда ничего не требуется генерить в рантайме. То есть почти всегда.
В моем случае почему-то это почти всегда. Типичный пример — построение запроса, основанного на пользовательском вводе.
Это больше про рефлекшн вообще
Рефлекшн без информации о дженериках ничего не смог бы сделать. Например я бы не смог реализовать такой метод:
internal static class AsyncRequestProcessorResolver
{
private static readonly MethodInfo ExecuteAsync = typeof(IAsyncRequestProcessor).GetRuntimeMethod("ExecuteAsync", new[] {typeof(IRemoteRequest) });
private static readonly MethodInfo GetResultAsync = typeof(IAsyncRequestProcessor).GetRuntimeMethod("GetResultAsync", new[] { typeof(IRemoteRequest) });
public static MethodInfo GetRequestMethod(MethodInfo interfaceMethod)
{
return interfaceMethod.ReturnType.GenericTypeArguments.Length == 0 ? ExecuteAsync : GetResultAsync.MakeGenericMethod(interfaceMethod.ReturnType.GenericTypeArguments);
}
}
Если бы у меня собственно не было MakeGenericMethod и interfaceMethod.ReturnType.GenericTypeArguments
Имеется ввиду, что он деконструируется на некоторые известные части. Типичный пример, построить лямбду x=>x.Name == "Alex" && x.Gender = Gender.M
на основании пользовательского текстового ввода.
Тяжело читать плюсы. Насколько я понял, мы от пользователя получаем только параметры запроса, который выполняем. Я имел ввиду скорее генерацию самого запроса с нуля. У нас на проекте, например, была фильтрация, которая могла иметь произвольную вложенность. Всевозможные фильтры, объединяемые через И-ИЛИ. Весь фильтр целиком по сути имел свойства (де)сериализации из/в пользовательский ввод и умение выполняться в БД. Примера из этой системы не покажу, но например я класс, который генерирует объекты сравнения. Например, пишем так:
var zComparer = ZComparer<Test>.New(t => t.A).Add(t => t.B).Add(t => t.C).Add(t => t.D);
var comparer = zComparer.ToComparer();
На выходе имеем объект типа IComparer
с методом compare
, который реализован как
public int CompareTo(Test x, test y)
{
var compA = x.A.CompareTo(y.A);
if (compA != 0)
return compA;
var compb = x.B.CompareTo(y.B);
if (compB != 0)
return compB;
var compC = x.C.CompareTo(y.C);
if (compC != 0)
return compC;
var compD = x.D.CompareTo(y.D);
if (compD != 0)
return compD;
return 0;
}
Можно ли тут сгенерировать нужный тип на этапе компиляции? Безусловно. Но только потому, что ZComparer<Test>.New(t => t.A).Add(t => t.B).Add(t => t.C).Add(t => t.D);
мы знаем на этапе компиляции. Если же у нас немного больше динамики
var zComparer = ZComparer<Test>.New();
if (userInputA)
zComparer= zComparer.Add(t => t.A);
if (userInputB)
zComparer= zComparer.Add(t => t.B);
var comparer = zComparer.ToComparer();
То сгенерировать нужный фильтр мы можем только в рантайме.
Чуть подробнее в исходниках и в тестах.
Код старый, так что возражения по оформлению и неоптимальности не принимаются, сам уже знаю :)
Темплейт же может таить в себе что угодно, и до момента инстанцирования сказать, рабочий ли он — невозможно.
Разные инструменты решают разные задачи, но в чем-то у них есть область пересечения. Дженерики позволяют решить задачу для произвольного типа Т, темплейты же позволяют писать факториалы времени выполнения, но никто не гарантирует того, что для любого возможного T реализация будет верна. Более того, это в общем случае неверно, ведь вместо типа может быть подставлено и число, и что угодно.
Если вы написали дженерик и он компилируется — то скорее всего он написан правильно. Поэтому он будет корректно работать с любыми типами, которые подходят под ограничения (если они есть).В этом плане отличий от шаблонов как раз минимум. Ведь ограничения точно так же кроются где-то в обобщенном коде, а компиляция сломается лишь при попытке использования неподходящего клиентского типа.
Могут ли такие гарантии быть у темплейтов?
where
еще не означает отсутствие ограничений. Там все типы наследуют некую общую функциональность типа Object
, кроме того вы не сможете создать объект конструктором по умолчанию и прочие такие вещи.Да, грустно, что автоматически соответствие алгоритма ассертам не проверяется до момента инстанцирования. Но я не исключаю возможности таких проверок, когда появятся концепты. И что тогда?
Ладно, перефразирую: чтобы понять, как будет работать дженерик с типом Т
достаточно посмотреть на сигнатуру метода. Если же ошибка в темплейте, единственный способ посмотреть — залезть в него и посмотреть, как он используется внутри. Это совершенно разная сложность.
Шарп/раст — не важно. Можно тип T никак не использовать, функционал "object" тут никак не задействован:
public T[] CreateArray<T>(int size) => new T[size];
в интерфейсе я вижу:
public T[] CreateArray<T>(int size) => new T[size];
interface IArrayCreator
{
public T[] CreateArray<T>(int size);
}
я не знаю, что внутри метода, я вижу только сигнатуру, и точно знаю, что он отработает с любым типом.
чтобы понять, как будет работать дженерик с типом Т достаточно посмотреть на сигнатуру метода. Если же ошибка в темплейте, единственный способ посмотреть — залезть в него и посмотреть, как он используется внутри.Если шаблон будет использовать концепт, то будет достаточно посмотреть на концепт, в шаблоне копаться не нужно.
Можно тип T никак не использовать, функционал «object» тут никак не задействованВы, как юзер, не знаете о том, используется он внутри или нет.
В C# — нет, не по тем же. Нельзя обращаться к членам типа-параметра невыводимым из ограничений
Вот такой код не скомпилируется, будет ошибка что у типа A не виден метод Baz:
interface IFoo {
void Foo();
}
void Bar<A>(A a) where a : IFoo => a.Baz();
В то же время аналогичный код на шаблонах в C++ будет компилироваться без ошибок до тех пор пока шаблоном не попытаются воспользоваться.
static_assert(
std::is_base_of<IFoo, T>::value,
"T must implement IFoo" );
?Вот смотрите, я пишу:
struct IFoo {
virtual void Foo() = 0;
};
template<typename A> void Bar(A &a) {
static_assert(std::is_base_of<IFoo, A>::value, "A must implement IFoo" );
return a.Baz();
}
И этот код компилируется без ошибок. Вот в этом "без ошибок" и проблема: в реализации серьезная ошибка, но компилятор ее не видит.
В то же время, если мы хотим максимум безопасности, нам ничто не мешает задекларировать
Baz
в интерфейс. Тогда ситуация станет точно такой же, как у дженериков.Это называется не "статический полиморфизм", а "утиная типизация".
В то же время, если мы хотим максимум безопасности, нам ничто не мешает задекларировать Baz в интерфейс.
Нам мешает тот факт, что мы об этом забыли, а компилятор не напомнил. И теперь библиотека будет в нерабочем состоянии до первого багрепорта от возмущенных пользователей.
То, что это не статический полиморфизм.
Статический полиморфизм — это сама возможность инстанцировать шаблон конкретным типом без виртуальных вызовов в рантайме.
А доступ к методу Baz когда нигде не описано что такой метод у типа A есть — это именно что утиная типизация.
Статический полиморфизм — это сама возможность инстанцировать шаблон конкретным типом без виртуальных вызовов в рантайме.У нас нет такой возможности?) Мы не можем позвать методы с подходящими сигнатурами от объектов типов, не состоящих в родственной связи?
Где я это писал? Вы вообще отличаете утверждения "вызов a.Baz() — это не статический полиморфизм" и "у нас нет статического полиморфизма"?
нигде не описано что такой метод у типа A естьА как же сам шаблон? Гарантия того, что этот метод есть — это ошибка инстанцирования в противном случае.
Вот и приходим к тому с чего началиМы продолжаем обсуждать термин «статический полиморфизм» в отношении шаблонов, вы мне так и не объяснили, почему же вызов метода по имени из объекта параметра-типа — это не статический полиморфизм?
чтобы увидеть реальный контракт шаблона надо изучить всю его реализациюЕсли весь контракт будет в концептах и других ограничениях, то это не потребуется.
Если весь контракт будет в концептах и других ограничениях, то это не потребуется.
… но вы никогда не сможете быть уверены в этом. Или вы живете в мире, где программисты никогда не ошибаются?
Или вы живете в мире, где программисты никогда не ошибаются?
назовите три примера ошибок в коде повсеместно используемых шаблонных библиотек
В двух словах:
- Если не компилируется код с дженериком — проблема в моем коде.
- Если не компилируется код с темплейтом — проблема может быть где угодно.
А мне ине не нужно знать.Верно, ибо это гарантируется дизайном языка.
Если не компилируется код с темплейтом — проблема может быть где угодно.Какие вы видите оганичения возможности вычислить соответствие шаблона концепту на этапе компиляции? Аналогично тому, как компилятор контролирует дженерики.
Верно, ибо это гарантируется дизайном языка.
При чем тут дизайн языка? В любом языке зная тип T
можно создать массив T[]
. Тут не используется ни одного метода или свойства Object
.
Какие вы видите оганичения возможности вычислить соответствие шаблона концепту на этапе компиляции? Аналогично тому, как компилятор контролирует дженерики.
Насколько я знаю, темплейты вообще не компилируются, а инстанцируются по месту использования. И любой синтаксически верный код скомпилируется, даже если семантически там полный бред.
Тут не используется ни одного метода или свойства Object.Юзер не может это знать, он видит сигнатуру.
Насколько я знаю, темплейты вообще не компилируются, а инстанцируются по месту использования.Хорошо, конкретизирую: на этапе статического анализа.
Юзер не может это знать, он видит сигнатуру.
Ну ок, допустим. Я не согласен, но допустим. Какая нам разница? Любой объект любого типа можно подставить вместо T? Можно. Что еще нужно?
Хорошо, конкретизирую: на этапе статического анализа.
Никакой статический анализ не скажет, что метода fooasgjknasgh1htg781gh73
не существует ни у одного объекта в проекте, и этот темплейт обречен провалиться.
template <typename T>
T add(T a, T b){
return a.fooasgjknasgh1htg781gh73(b);
}
Никакой статический анализ не скажет, что метода fooasgjknasgh1htg781gh73 не существует ни у одного объекта в проекте, и этот темплейт обречен провалиться.Ну а дженерики-то какие дадут преимущества? Для этого вы объявите интерфейс IFoo и сделаете дженерику
where T: IFoo
и он точно так же повалится лишь тогда, когда выяснится, что пользовательский тип не имплементит IFoo
.Я о другом говорил: компилятор должен проверить, чтобы любой тип, подходящий под концепт, был валидным и для шаблона его использующего.
То есть нужен аналог сишарпового
where
, только с более широкими возможностями ограничений.Ну а дженерики-то какие дадут преимущества? Для этого вы объявите интерфейс IFoo и сделаете дженерику where T: IFoo и он точно так же повалится лишь тогда, когда выяснится, что пользовательский тип не имплементит IFoo.
Ничего подобного. Дженерик повалится тогда, когда окажется что в интерфейсе IFoo нет метода fooasgjknasgh1htg781gh73.
Ну а дженерики-то какие дадут преимущества?
В том, что будет ошибка "не реализован интерфейс IFoo", а не "не найден метод fooasgjknasgh1htg781gh73
" Преимущество очевидно тогда, когда у вас темплейт вызывает другие темплейт-функции, и падает где-то в глубине нижних уровней из-за какого-то непонятного несоответствия.
Во-вторых, функционал шаблона может зависеть от свойств типа-параметра. vector от unique_ptr не поддерживает копирование, т.к. unique_ptr не поддерживает копирование
В третьих, концепты и позволят накладывать все необходимые ограничения на тип. Ошибка возникнет в момент инстанцирования шаблона, и укажет именно на то требование к типу, которое не выполняется
Никакой статический анализ не скажет, что метода fooasgjknasgh1htg781gh73 не существует ни у одного объекта в проекте, и этот темплейт обречен провалиться.
как раз то о чем я говорил:
requires (T a, T b) { a.fooasgjknasgh1htg781gh73(b) }
На enable_if сложнее, но тоже реализуемо
Ошибка возникнет в момент инстанцирования шаблона, и укажет именно на то требование к типу, которое не выполняется
Так в том-то и проблема, что все требования к типу выполняются, но ошибка компиляции все равно возникнет. Потому что ошибка — в самом шаблоне.
во-вторых, посмотрите на это вот с какой стороны: за корректность библиотеки и проверку входных параметров/данных отвечает библиотека.
Я как-то не задумывался, что дженерики работают и в рантайме. Тогда для некоторых задач, они становятся удобнее шаблонов, в частности сохранения отношения наследования, или инвертирование его.
Ну, в расте райнтаймовой поддержки на данный момент нет, т.к. раст все же обычно имеет зависимости на уровне исходников.
А вот в том же шарпе да, есть полная поддержка со стороны среды. И например List<int>
и List<string>
там разные типы, и попытка засунуть одно в другое приведет к ошибке времени выполнения, даже если на этапе компиляции там все было ок.
Такое поведение не может быть общим, но с дженериками это можно реализовать.
У вас угловые скобки потерялись...
Нет, в общем случае Templ<Derived>
нельзя использовать в качестве Templ<Base>
, как и наоборот. Если бы было можно — это привело бы к многочисленным нарушениям LSP и просто ошибкам в рантайме.
То, что вы просите, в языке C# работает только для обобщенных интерфейсов, которые отмечены как ковариантные (это накладывает на интерфейс дополнительные требования на этапе компиляции).
В Java ковариантных интерфейсов нет, но зато можно писать конструкции вида Templ<? extends Base>
(такой класс "теряет" свои нековариантные методы) — и к вот такому типу действительно можно неявно привести Temp<Derived>
.
PS вот тут я совсем недавно пример приводил: https://habrahabr.ru/post/348286/#comment_10654282
Про угловые скобки — спасибо, не заметил.
Да, я именно про ковариантность говорил. Это без условно накладывает определенные ограничения на интерфейс класса и не может быть обобщено на все шаблоны. Но если говорить о немутабельных коллекциях, то это несколько упрощает жизнь. В этом случае коллекция будет приводится к наиболее общему типу. Это могло бы выглядеть как-нибудь так:
template<typename T>
class List {
//...
template<typename T1, typename TMain = find_main<T, T1>>
List<TMain> add(TMain item);
//...
};
var empty = new List<Circle>; //пустой список
var circles = empty.add(new Circle()); //список Circle
var shapes = circles.add(new Rectange()); //а здесь уже List<Shape>
Но больше пользы будет при работе в C++ со smart_ptr, в части возвращения значений.
Например такой вариант сейчас валиден:
struct Interface;
struct Factory {
virtual Interface* create() = 0;
};
struct ImplInterface: Intreface {};
struct SomeFactory: Factory {
ImplInterface* create() override;
};
То такой вариант уже не валиден:
struct Interface;
struct Factory {
virtual uniqie_ptr<Interface> create() = 0;
};
struct ImplInterface: Intreface {};
struct SomeFactory: Factory {
uniqie_ptr<ImplInterface> create() override; //Ошибка
};
Иногда такое удобно. Но для этого еще потребуется такая перегрузка:
struct Base1 {};
struct Derived1: Base1 {};
struct Base2 {};
struct Derived2: Base2{};
struct BaseInterface {
virtual Base1 foo (Derived2);
}
struct DerivedInterface {
Derived1 foo (Base2) override;
}
То такой вариант уже не валиден:
Из вашего примера не понятно, какая именно из четырех ваших ошибок связана с недопониманием. Убедитесь, что Interface определен и имеет виртуальный деструктор.
Просто иногда удобно, когда в базовом классе метод возвращает, например, указатель на тип Q, а в классе наследнике метод перегружен и возвращает указатель на тип W — наследника Q. Таким образом когда работаешь с классом наследником, в той части программы, которая про него знает, можно работать с указателем на W, а общая часть, которая не знает о наследнике работает только с указателем на Q.
Но если речь заходит о возврате умного указателя, то код не скопилируется, так как нарушается ковариантность.
struct Base1 { virtual ~Base1(); /*...*/ };
struct Derived 1 : Base1 { /*...*/ };
struct Base2 { virtual ~Base2(); /*...*/ };
struct Derived 2 : Base2 { /*...*/ };
struct BaseContainer {
Base1 *base1;
Base2 *base2;
}
struct DerivedContainer {
Derived1 *derived1;
Derived2 *derived2;
}
DerivedContainer ковариантен BaseContainer, хотя не является (умным) указателем/ссылкой и даже его не наследует.
Нельзя «всё сделать указателем». Это 0-cost abstraction язык.
Вы немного путаетесь с терминологией. Ковариантность — это свойство параметризованного типа (шаблона или дженерика) в отношении одного из своих параметров, но никак не отношение двух классов.
Что же до вашего случая — тут как раз все очень просто:
struct DerivedContainer {
Derived1 *derived1;
Derived2 *derived2;
operator BaseContainer() const {
BaseContainer r;
r.base1 = derived1;
r.base2 = derived2;
return r;
}
}
Спасибо за уточнение, про теминологию. Но в разрезе C++ это почти соответствует такому шаблонному коду:
template<typename T1, typename T2>
struct Cont
{
T1* field1;
T2* field2;
};
using BaseContainer = Cont<Base1, Base2>;
using DerivedContainer = Cont<Derived1, Derived2>;
И вроде они могли бы быть коварианты, но увы.
Можно даже было бы написать обощенный оператор приведения.
template<typename T1, typename T2>
struct Cont
{
T1* field1;
T2* field2;
template<typename B1, typename B2,
typename = enable_if_t<
is_base_of<B1, T1>::value
&& is_base_of<B2, T2>::value>>
operator Cont<B1, B2>()
{
Cont<B1, B2> c;
c.field1 = field1;
c.field2 = field2;
return c;
}
};
Но к сожалению наличие оператора приведения типа никак не поможет перегружать виртуальные метод с возвратом типа который отличается от того, который был указан при объявлении виртуального метода в базовом классе.
struct BaseStorage
{
virtual BaseContainer foo() {
/*...*/
}
};
struct DerivedStorage: BaseStorage
{
DerivedContainer foo() override { //Ошибка
/*...*/
}
};
Это потребует хранение дополнительной информации о необходимых трансформациях возвращаемого значения или генерации некоторой хитрой обертки над таким методом, но все это будет приводить к путанице при последующем наследовании и перегрузке.
Теперь о недостатках вашего решения.
Вложенный контейнер работать не будет:
Cont<Derived1, Cont<Derived2, Derived3>>
нельзя привести кCont<Base1, Cont<Base2, Base3>>
;
- dynamic_cast тоже в такой реализации не работает
А не мешанина из кишков темплейта.
вот как раз таки концепты позволяют решить в т.ч. и эту проблему.
// Допустим, определен концепт Error
template <typename T>
concept bool Error = std::is_error_condition_enum<T>::value;
void print(Error e) {
cout << error_condition(e).message() << endl;
}
print(std::errc::argument_out_of_domain); // "Numerical argument out of domain"
// print(5); // ошибка компиляции, текст ниже
../src/main.cpp: In function ‘int main()’:
../src/main.cpp:211:12: error: cannot call function ‘void print(auto:2) [with auto:2 = int]’
print(5);
^
../src/main.cpp:181:6: note: constraints not satisfied
void print(Error e) {
^~~~~
../src/main.cpp:179:14: note: within ‘template<class T> concept const bool Error<T> [with T = int]’
concept bool Error = std::is_error_condition_enum<T>::value;
^~~~~
../src/main.cpp:179:14: note: ‘std::is_error_condition_enum<int>::value’ evaluated to false
auto ar[] = {1,4,7,9};
if( 4 in ar )
{
}
vector<int> vec;
vec.push_back( 10 );
vec.push_back( 20 );
for (int i : vec )
{
cout << i;
}
set<int> myIntSet = { 1, 4, 7, 9 };
if ( myIntSet.find( 4 ) != myIntSet.cend() )
{
}
К тому же я написал про array (если бы вы смогли понять что я написал в коде), а не неудачные примеры в STL.
я написал про arrayНет, вы писали про множества) Если там будет
std::array
, то это мало что изменит.ЗЫ Стандарт С++ включает стандартную библиотеку, так что смело можете считать, что любые контейнеры и алгоритмы из нее — это и есть языковая поддержка.
Пожалуй, я бы еще boost сюда включил, но это уже будет не так честно.
template<typename T>
concept bool MyComparable = requires (T a, T b) {
// Эта проверка не нужна, т.к. корректность операции проверяется строкой ниже
a < b;
// Проверка: "операция a < b определена и её результат контекстуально приводится к bool"
{ a < b } -> bool;
};
Очень много реального сахара не описано (подробнее можно глянуть здесь). Пример:
template <typename T>
concept bool ConstrainedType = ...;
void func(ConstrainedType c); // короткая запись
template <ConstrainedType T>
void func(T); // Расширенная запись
template <typename T>
requires ConstrainedType<T>
void func(T); // полная запись
Полная запись позволяет накладывать ограничения на функцию без объявления нового концепта (bb enable_if). Или, например:
void func(std::vector<ConstrainedType> v); // Проверка типа - аргумента шаблона
ConstrainedType func(auto a); // Проверка типа возвращаемого значения
ConstrainedType val = func(args...); // Проверка типа переменной
В общем, всё самое интересное в статье опущено
Концептуальная сортировка в С++20