Comments 46
Насколько понял Ваш основной аргумент в защиту расширений (а также аргументацию из статьи по ссылке): такие выражения:
x.f(y) и f(x, y) являются идентичными (х - инстанс класса, f - метод, y - некий аргумент). А если идентичны, то давайте делать расширения - ведь это так удобно.
Тут видится пара шероховатостей:
Если опираться на саму команду вызова (в ассемблерном коде), то это верно - да, оно идентично.
Если опираться на конструкции языка C++, то (по-моему) возникают вопросы с типом первого аргумента, передаваемым в функцию f(x, y).
Это сам инстанс? - А если его нельзя копировать (например, std::mutex)? Или копирование очень "жирное" по ресурсам?
Это константная ссылка? - И сразу ограничиваемся в вызове методов и доступе к публичным полям (упоминавшийся выше мьютекс - не залочить).
Обычная ссылка? - Может не получиться из константного объекта.
Плюс, это всё должно работать в случае x как r-value. И вот как-то это уже "не очень".
Как результат, утряска всех этих тонкостей может очень дорого обойтись и стандартизаторам, и программистам.
-
Почему в других языках это заходит?
Где-то объектами оперируют только через указатели, где-то аналогов r-value нет.
Возможно, в этом причина.
Я вот смотрю на весь этот код и не понимаю, а к чему вся эта графомания в примерах из статьи? Унаследуйте класс, добавьте методов по вкусу и пользуйтесь, не?
Унаследоваться можно далеко не от всего, и далеко не всем можно сказать, что надо использовать унаследованную форму.
Например, речь о примитивных типах. Или строки, конструирующиеся из строковых литералов.
Правильно ответили. Наследование это сложнее (из-за конструкторов) и не возможно, когда это библиотечный код. Да и обобщённый код сложнее написать, чтобы был один шаблонный метод и работал с множество разных типов. С шаблонными функциями это всё возможно, но пользоваться неудобно (пример - алгоритмы в C++).
Это сработает, только если вы контролируете место создания объекта. А если вам приходит уже созданный объект?
Мы же говорим про состояние до начала компиляции? Как я могу потерять контроль над местом создания объекта? А если объект приходит уже созданный, то это DLL export чтоли?
Про шероховатости. Не совсем их вижу. Мы же пишем функцию, там явно указываем тип ссылки на объект, *, &. const ... Компилятор только должен её увидеть и попробовать подставить. В приоритете конечно методы объекта, но потом можно пробовать и функции с подходящей сигнатурой. То что творится в шаблонах вас не смущает, SFINAE например, когда компилятор перебирает кучу всего подряд пытаясь найти подходящее?
Методы-расширения обобщаются до UFCS, т.е. это должно работать в обе стороны: функция может вызываться как метод от своего первого параметра, и метод может вызываться как функция (статический метод класса) с явной передачей первого параметра. Фича интересная, но почему-то стандартизаторы никак не могут договориться. Хотя казалось бы, всего лишь синтаксический сахар.
А то что у вас в примере - это эмуляция языковых возможностей. Результат ситуации, когда программисты хотят какую-то возможность, язык ее не предоставляет, но зато язык предоставляет слишком большую свободу при использовании других возможностей. И по моему мнению, лучше таких вещей избегать - это нарушает целостное восприятие самого языка, создает многочисленные "диалекты".
Да, всё верно. Вроде такая простая и полезная фича, а затянуть не могут. Мне не понятно. Зато выкатывают какие-то мудреные странные фичи для каких-то корнер кейсов.
Я поработал с этим в других языках, начиная с C# в 12 году, мне очень зашло, теперь жить без них не могу.
Я проэмулировал, оно немного коряво, но на практике пользоваться удобно, написал один раз помучился, но потом кайфуешь. Ну а про диалекты, чтож, в плюсах это на каждом шагу. Кто-то макросы изобретает, кто-то шаблонную магию, которую очень сложно понять. Таков язык. Если в проекте это используется системно, то все привыкают и тоже используют. Да и std::ranges в принципе это то же самое, только понавороченнее.
А Вы посмотрите, как это было сделано в языке D! Вот там это действительно красиво.
В C# всё-таки немного бойлерплейта надо писать.
Да в 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.
Возможно мы в каком-то стандарте и увидим такой синтаксический сахар, может и доживём.
Программист читает код, видит:
std::cout << std::stoi("10") << std::endl;
И спокойно читает дальше.
Если же, программист читает код и видит:
std::cout << ("10" | toInt()) << std::endl;
То это потребует как минимум 5...10% рабочего времени на понимание того, что здесь происходит. А зачем, вообще лучше не задумываться.
То есть, это инструкция на тему как украсть рабочее время.
Ну потратил он один раз 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();
А если бы в плюсах не было бы столько приседаний, чтобы писать лямбды...
Сравните:
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 ", "
Да в плюсах с лямбдами туго, мне тоже не хватает упрощённого синтаксиса.
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(",")
Ну потратил он один раз 5 минут разобрался и дальше проблем не возникает.
Для разбора приведенного примера, а тем более для того чтобы подобные вещи читать бегло, нужно существенно более 5 минут.
Мы наблюдаем большое количество вложенности, обилие открывающих и закрывающих скобочек, код читается сложнее, можно запутаться какие параметры в какую функцию передаются.
Звучит похоже на рекламу магазина на диване:
Фактически вы просто использовали другой оператор для того, что уже давно и так известно и широко применяется -- перегрузка операторов <<
и >>
. Это ведь и есть те самые "методы расширения". А умные указатели перегружают operator ->
, не уверен, но вроде ничего не мешает перегрузить его как внешнюю функцию и даже будет привычный синтаксис вызова. Но как мне кажется, это сильно усложнит понимание написанного кода.
Фактически вы просто использовали другой оператор для того, что уже давно и так известно и широко применяется -- перегрузка операторов
<<
и>>
Да, я перегрузил |
по аналогии с библиотекой ranges, хотя можно было перегрузить и оператор >>
, было бы нагляднее.
items >> map() >> filter() >> collect()
К сожалению оператор ->
и .
нельзя перегрузить вне класса. Да и это бы вносило путаницу с указателями.
Это ведь и есть те самые "методы расширения".
Да, но в стандартной библиотеке это применимо только к потокам. Я просто обобщил. Ну и флоу тут "обратный" потокам.
Зато можно сделать подлянку и перегрузить оператора ,
О даааа =)
Жалко что нельзя свои операторы определять, вот тогда бы вообще раздолье было бы )
Кстати в Котлин есть инфиксные функции, наверное и в других каких-то языках есть тоже. Можно делать так:
infix fun <A, B, C> then(a: A, b: T): C { TODO() }
// и потом писать такое
1 then "lala" then false
Пристёгивайтесь крепко!
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
есть инфиксные функции
В ObjectiveC было такое:
- (int)add:(int)x to:(int)y;
int result = add: 2 to: 3
Аргументы идут внутри названия метода!
что насчёт оператора "->*", кстати? Я глянул: его можно спокойно вне класса перегрузить. И по общей логике подходит. Единственное, не превращать его использование в то, что в первой строчке, ибо приоритет у него чуть ниже, чем у "()", что не совсем интуитивно:
(obj_ptr ->* ptr) (param) //реальность использования указателей на метод
obj ->* extention(param) //ожидание или если писать как конвеер
Не знаю, о чём думали создатели стандарта
Библиотека std::ranges основана на библиотеке range-v3, которая работает на C++14 и новее. Поэтому можно эту библиотеку добавить в свой проект. Кроме того, на сколько я помню, в std::ranges вошли не все возможности из range-v3.
Эта фича довольно старая, но применять её надо с осторожностью. Как писали выше, это черевата созданием нового диалекта для конкретно вашей либы а так же нарушением ADL, что иногда плохо заканчивается.
Использование функций для этого не совсем кошерно, можно использовать constexpr переменные, дабы получить более вкусный синтаксис. Условно (value->as_int)
навариваем сюда концептов и получаем забавный способ что-то сделать
Ну я это не в библиотеке использую, а в своём проекте. В библиотеке я, наверное бы, не стал навязывать такие интерфейсы.
Использование функций для этого не совсем кошерно, можно использовать constexpr переменные, дабы получить более вкусный синтаксис. Условно
(value->as_int)
навариваем сюда концептов и получаем забавный способ что-то сделать
Я думал о глобальных константах вместо функций. Но есть функции с параметрами, их constexpr переменными не сделаешь.
> Не разбирался в std::ranges
А лучше бы разобрался и узнал что через adl можно замечательно перегружать любые имена функций, а не только пайпы и одним лёгким ниеблоидом их диспатчить (см. реализацию std::ranges::begin)
Отлично. Давно уже, где-то с 17 стандарта, тоскую без расширений. Фича-то, казалось бы, простецкая и безопасная, но такая нужная.
Обязательно опробую ваш подход
Как по мне, намного более удобная фича Kotlin, которой мне не хватает в C++ - это чейн-функции вида also/apply/let/run/ ...
Так я же привёл пример реализации функции transform
, это как раз и есть прямой аналог let
в Kotlin. Остальные функции можно сделать по аналогии.
11 | transform([&](auto v){ return (std::stringstream() << v).str(); })
И я надеюсь, что компилятор это способен оптимизировать до простых вызовов.
Глупости. Это разве похоже на вызов через точку? Ни разу т.к. в плюсах нежелательно перегружать оператор "точка"
И плюс, в плюсах нет возможности последний аргумент являющийся лямбдой вынести за круглые скобки и не писать их вообще:
func(lambda)
vs
func { /*lambda body goes here */ }
В вашем комментарии речь шла о чейн функциях, в моем примере они есть. Не понимаю в чем вы видите различие вызов через точку или вызов через |
? Семантически это одно и то же. И да лямбды в плюсах уродливые, тут ничего с этим не сделаешь.
И в плюсах оператор точку не то чтобы нежелательно, её нельзя перегрузить для чужого класса.
Лямбда - это функциональный объект анонимного типа. То есть у него есть оператор круглые скобки с необходимыми аргументами, как и у функции. А захваченные переменные становятся полями такого объекта.
Объект лямбды можно сконструировать:
auto lambda = (auto v){ return (std::stringstream() << v).str(); };
Тип лямбды можно вывести через decltype(lambda).
Объект лямбды можно передать в функцию.
Новый объект лямбды можно сконструировать.
В языке запретов на это никаких нету. Но смысл лямбды как раз в том, что нет смысла знать её конкретный тип и не надо писать код по конструированию - синтаксический сахар сделает это за вас.
Не совсем понял к чему тут объяснение что такое лямбда? Причем, у вас пример не правильный, не хватает []
в начале.
синтаксический сахар сделает это за вас.
Какой синтаксический сахар? Синтаксис лямбды в C++ сложно назвать сахаром 😅
Да, недокопировал скобки. Но суть от этого не меняется.
Но, простите, что есть лямбда, если не синтаксический сахар поверх функционального объекта, который суть под капотом? Аналогичные вещи можно было делать и до появления лямбд, но руками. А теперь можно сделать то же самое, но короче и лаконичнее.
Ещё раз о методах расширения классов в C++