Pull to refresh

Comments 49

PinnedPinned comments

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

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

Вот это совпадение! Я рад встретить единомышленников. :)

Dart? Язык немного взлетает только благодаря Flutter.
Отвечу сюда, потому что для C++ такое нагромождение шаблонной магии ещё как-то можно оправдать, но Dart, как замена JS, там порог вхождения в отрасль чуть ниже, так что вы их только напугаете.

Подобное использование "типов" абсолютно бесполезно и ухудшает код и производительность

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

То, что написано в последней трети это вообще, простите(нет), мусор. using Result = expected и Type::New неоднозначно намекают откуда копируется мусор.

Просто напомню, что в С++ существуют конструкторы и ещё миллиард способов для создания адекватной инкапсуляции

Производительность в случае со Scala не уменьшается. В отношении Go, если использовать просто type definition, то не должно влиять. А в C++, если делать подобную обертку, оптимизируют код. Без такой оптимизации, тот же std::unque_ptr был бы крайне неэффективным. Если говорить о простых типах типа int, то разницы совсем не будет. В случае с более сложными, как std::string, да, появляются некоторые накладные расходы. Можно посмотреть результат компиляции: https://godbolt.org/z/7o8bP91jz

Что до ухудшения кода - это скорее понятие вкуса. Кто-то считает излишеством, кому-то нравится. Мне и команде на одном из проектов подобное понравилось и мы активно использовали. В других командах к таким предложения относились скептически.

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

  1. производительность в скале просто по умолчанию неочень

  2. в С++ компилятор конечно постарается, но во первых вы добавляете лишние конвертации, во вторых запрещаете много где RVO, про то что вы из примитивных типов делаете не примитивные и во что это выльется для регистров и прочего промолчу

  3. Этот код просто напросто не несёт никакой смысловой нагрузки, он бесполезен

  4. Если у типа ДЕЙСТВИТЕЛЬНО есть что проверять (инварианты), то он делает это через private и конструкторы + методы

  5. первые две трети статьи заменяются на именованные аргументы функций


    https://godbolt.org/z/h61663bsn

struct foo_named_args {
    int v1;
    float v2;
};
void foo(foo_named_args) {}

int main() {
    foo({.v1 = 5, .v2 = 3.14});
}

Правильно именованные аргументы, не всегда спасают, так как при совпадении типов компилятору всё равно какое имя. Так же может сработать неявное приведение, которое не всегда желательно.

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

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

Если у типа ДЕЙСТВИТЕЛЬНО есть что проверять (инварианты), то он делает это через private и конструкторы + методы

Да, ровно это я в статье и делаю. Приватное поле + метод для создания, чтобы не бросать исключение из конструктора.

в С++ компилятор конечно постарается, но во первых вы добавляете лишние конвертации, во вторых запрещаете много где RVO, про то что вы из примитивных типов делаете не примитивные и во что это выльется для регистров и прочего промолчу

RVO идёт лесом из-за возврата std::expect, вместо конкретного типа?

А в C++, если делать подобную обертку, оптимизируют код

В теории да, на практике не всегда. Помню, однажды в очередной версии компилятора замедлился какой-то алгоритм типа std::sort с таким типом-оберткой. В следующей версии это поправили обратно. Что это было, так и не понял

Да говорят что и unique_ptr не максимально оптимальный. В проектах с прям серьезными требованиями по производительности его не используют

Ну потому, что у него под капотом примерно то же самое. :) И да, встаем перед выбором сделать что-то оптимальнее с точки зрения производительности или уменьшить вероятность ошибки программистом, но ценой замедления программы.

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

А каким макаром оно на производительность влияет? Оно же на этапе компиляции по идее такое заинлайнит в честные простые типы. Можно ещё constexpr конструкторы сделать, чтобы наверняка. На сложных шаблонных типах есть нюансы, но речь вроде изначально за достаточно простые типы.

адекватной инкапсуляции

так речь не за инкапсуляцию, а за нормальные new types. Какие ваши предложения?

  1. Влияет

  2. Заинлайнить тип это что-то новенькое

  3. Причём тут constexpr абсолютно непонятно

  4. Как на это влияют шаблоны тоже абсолютно непонятно

Показываю как создать new type в С++

struct my_new_type {

}

Это не newtype в том смысле в котором его используют в ФП.

а я вам подсказываю, что это С++ и тут это не имеет смысла

Видимо потому что это не имеет смысла у нас есть всякие <chrono> с его seconds, miliseconds и прочими, которые оборачиваются вокруг обычных интов при помощи шаблонов.

template<

    class Rep,
    class Period = std::ratio<1>

> class duration;

В этом типе сохранено кроме значения ещё куча информации, а не бесполезные тег-типы

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

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

Я не говорил что тег тип портит перфоманс, он портит код. Валидацию делает обычный конструктор

В методиках защитного программирования и борьбы со сложностью (а newtype является одновременно и тем и другим) всегда есть смысл, в любом языке (но не всегда есть возможность, увы).

это усложняет, а не упрощает

Зачем ts если есть js, где все гибко?

Вопрос был каким образом влияет, а не влияет или нет.

Шаблоны разворачиваются на этапе мономорфизации типов и после проверок соответствия превращаются в самые простые типы - то что я назвал инлайнингом, хотя корректнее называть это type elimination. То есть результирующий код будет как минимум не медленнее чем если ткнуть туда обычные простые типы, а в некоторых ещё и быстрее, если ньютайп позволил наложить дополнительные ограничения и сгенерировать радость для branch prediction.

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

struct my_new_type {

}

Как это использовать? Большая часть кода все ещё в процессе перехода к 17 стандарту, а designated initialization только в 20+ появился, так что пользоваться по красоте что-нибудь типа `{.timestamp = 265312674}` не выйдет.

Как идея - хорошо, как реализация - плохо. По крайней мере, для случая C++. Никто не будет использовать для замены примитивного типа уродца с std::function<> внутри, который, в общем случае, выделяет память в куче. Зато в C++ можно разрешить типу поддерживать определённый набор операций и синтаксически его использование не будет отличаться от использования встроенных типов.

Реализацию можно и доделать под свои нужды. Как один из вариантов - в структуру тег добавить функцию, которая и будет выполнять проверку. Вариантов реализации масса.

Зато в C++ можно разрешить типу поддерживать определённый набор операций и синтаксически его использование не будет отличаться от использования встроенных типов.

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

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

Реализацию можно и доделать под свои нужды. 

Если ты бездомный - просто купи себе дом.

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

Да конечно, если под каждый тип надо писать две страницы кода, никто не будет это использовать. А если это можно сделать двумя строчками кода - будут использовать все.

В шарпе DateTime используют ведь (а внутри обычный long).

Да конечно, если под каждый тип надо писать две страницы кода, никто не будет это использовать. А если это можно сделать двумя строчками кода - будут использовать все.

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

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

А если все абстрагировать, то потом всё равно, для удобства, придется это «приземлять» на контекст проекта и всё равно писать разово дополнительный код. При этом, если идею реализовывать самостоятельно под проект, то можно сделать более оптимально как по удобству, так и по производительности.

Никто не будет использовать для замены примитивного типа уродца с std::function<> внутри, который, в общем случае, выделяет память в куче.

+1

ЕМНИП, в нулевых было модно экспериментировать с различными вариантами bounded_value<T, L, R> (простейший случай -- это int с ограниченным диапазоном значений, вроде bounded_value<int, 32, 64>), а так же с еще более обобщенным случаем constrained_value<T, C>, где C должен был быть типом-валидатором для значений. В самом простейшем случае это должна была быть структура со статическим методом bool check(const T&). Странно, что автор не пошел по этому простому пути, а придумал код ошибки с множественными описаниями проблем внутри + составной валидатор из цепочки std::function.

Можно и так, просто вместо списка валидаций один метод. Тут кому, что ближе и как удобнее. У меня не было цели дать готовый рецепт. Множественное описание - вопрос удобства. Зависит от того, что принято на проекте. Можно было и просто строку возвращать.

А что касается constrained_value<T, C>, тоже можно, похожее использовал. Просто мне больше нравится когда и нижележащий тип и валидатор связаны в одном вспомогательном типе теге. Собственно задача различения двух типов так же может быть решена разными способами, о чем я и упомянул.

Да и std::expected я бы в рабочий проект пока не потащил бы, так как поддерживается еще не всеми компиляторами. Я не претендую на истину в последней инстанции, лишь показываю свое виденье того как можно. Важна идея, потенциальная возможность сделать что-то, а реализацию каждый подберет для себя, если захочет.

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

Можно и так, просто вместо списка валидаций один метод. Тут кому, что ближе и как удобнее.

ИМХО, в демонстрационных целях желательно использовать простые примеры. Так людям проще разбираться в предмете. Ваше же демонстрационное решение выглядит почти как production ready, но к нему сходу можно высказать ряд обоснованных претензий, что вы и получили.

Да и std::expected я бы в рабочий проект пока не потащил бы

Для старых компиляторов есть замечательный expected-lite.

Да и std::expected я бы в рабочий проект пока не потащил бы, так как поддерживается еще не всеми компиляторами.

std::expect ещё долго сомневался, стать ли ему стандартом в C++23, а мы уже давно использовали реализацию от TartanLlama
Пользуясь случаем, добавлю, что ещё мы fmtlib так тащим, потому что std::format сосёт.
Ещё мы тащим chrono которого недопилили в C++20.
Я уже говорил про std::ranges?
А вообще, я зол на них, что C++20 модули завезли только в VC++.

Спасибо, что поделились, утащу в закладки к себе!

Но зачем нужен expected, если есть эксепшены?

С перфом у эксепшенов все очень даже неплохо (Пропозал с замерами), так что мне вообще не понятно, почему с ним так носятся.

Этож std::optional с оверхедом множественными if на стороне вызова который заполняет happy-path - бойлерплейтом проверки ошибок.

Кстати, отказ от эксепшенов приводит к тому, что RAII перестаёт нормально работать и появляются всякие 2ух этапные конструирования и IsValid(). А многоуровневые std::expected которые прокидываются через 3 слоя функций до места где это можно обработать, звучат так себе.

Но зачем нужен expected, если есть эксепшены?

Во многих проектах отключают exceptions и rrti, например Google C++ Style Guide явно про это говорит, и по ссылке описываются за/против.

Этож std::optional с множественными if на стороне вызова который заполняет happy-path - бойлерплейтом проверки ошибок.

Ты и в Си также руками пишешь, ```FILE* fp = fopen(..); if (fp != NULL)...````

Кстати, отказ от эксепшенов приводит к тому, что RAII перестаёт нормально работать и появляются всякие 2ух этапные конструирования и IsValid().

Ты имел ввиду, что без эксепшенов нет возможности из конструкторов сообщить, что произошла ошибка? Вообще хорошая манера делать конструкторы noexcept, а конструировать отдельной функцией типа std::optional<Something> MakeSomething(args), которая и вызовет приватный конструктор типа Something.

Вообще хорошая манера делать конструкторы noexcept

O_o, афигеть.

Вообще хорошая манера делать конструкторы noexcept

Вообще, хорошая манера - не писать чушь. Тем более по тем вопросам, в которых не разбираешься.

Но зачем нужен expected, если есть эксепшены?

Мне больше нравится expected и его аналоги, тем, что по сигнатуре функции понятно, что может произойти ошибка и какая именно. В случае с эксепшеном это менее очевидно, особенно когда их обработка идет n-уровнями выше - сложно контроллировать какие ошибки могут появится, какие ситуации надо обработать. Единственный способ понять, какие исключения могут быть - это заглянуть в код функции и в код всех вызываемых функций, который не помечаны как noexcept.

отказ от эксепшенов приводит к тому, что RAII перестаёт нормально работать

Не понимаю, почему RAII перестает работать без исключений? Как работал так и работает, ресурс получили, при выходе из функции ресурс освободили. Если речь идет о конструировании объекта через функцию, которая возвращает expected, то ресурсы захватили так же при помощи RAII, провели валидацию всего и если всё хорошо, то все параметры и захваченные ресурсы передали в конструктор типа и вернули значение, если что-то пошло не так - вернули ошибку, а ресурсы автоматом освободились.

А многоуровневые std::expected которые прокидываются через 3 слоя функций до места где это можно обработать, звучат так себе.

Откуда же взяться многоуровневым expected? Вы или работаете с ними в С-стиле, когда проверяете код возврата функции и тут же выходите. Другой вариант, когда вы используете функции для комбинации expected, которые принимают один или несколько expected и функцию, которая возвращает значение или expected.

Из, относительных, минусов, expected заставляет продумать иерархию ошибок, либо делать их совместимыми по типу, как-то оборачивать, чтобы можно комбинировать такие значения. В случае с исключениями - бросай, что хочешь и откуда хочешь (ну кроме деструкторов, из них не желательно, они по умолчанию noexcept).

Есть такой язык - Ada. Мне кажется там есть многое из того, о чём пишет автор...

Концепты, которые рассматривает автор, имеют мощный потенциал, однако в плане реализации он использует едва ли подходящие инструменты. Вещи, которые автор хочет, представляют лишь строготипизированные языки с жесткой проверкой компилятора. По истине почувствовать данный потенциал он бы мог на Rust, Haskell или OCaml

В указанных Вами языках это сделать можно было бы проще, хотя если сравнивать Haskell и Scala, то прям сильных отличий не вижу. Да, в Haskell есть newtype, но сам по себе они ничего не дает. Собственно как и в Scala конструкция opaque type.

Т.е. если хочется, чтобы новый тип обладал некоторыми свойствами: сериализация/десериализация в нужном формате, единообразная валидация при создании, как логировать. Это всё равно придется делать в ручную для каждого такого типа, писать тайп классы.

Если хочется сделать что-то подобное, то и в указанных Вами языках придется делать некоторую абстракцию, которая позволит собрать все части во едино. Хочется не просто иметь возможность объявить новый тип, но чтобы он сразу подхватил все необходимые в рамках проекта свойства.

Я буду рад ошибиться в отношении этих языков. :)

В статье у меня смешалось две вещи.

  1. Что-то типа newtype из Хаскель. Возможность создавать новые типы, которые базируются на каких-то существующих и лего различать их. В случае с Scala и Go это есть на уровне языка, а вот с C++ приходится делать обертки из структры.

  2. Как сделать так, чтобы за минимум движений потом можно было бы создавать типы обладающие сразу нужным набором свойств.

По комментариям, в том числе и вашему, я понял, что не правильно построил статью.

typename Tag::Type value

Я бы в стиле C++библиотеки назвал typename Tag::value_type

в языках такие проверки могут называться assert или require

Наверное тут имелось ввиду requires из c++20

Нет, имелось в виду именно слово require. Оно не из C++, в C++ оно называется assert.

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

При описании модели данных, часто приходится создавать новые типы, в первую очередь, используя такие ключевые слова как class/struct/record
...
using Timestamp = int64_t;

Настоящие герои пишут enum class Timestamp : int64_t, ещё и когда складывать начнёшь, то тебе компилятор по рукам надаёт.

Чёрт, как же сам до этого не додумался, а ведь когда-то сокрушался, что гарантий на значение enum в целом нет и туда положить можно что угодно.

Жаль нельзя для enum указывать произвольный тип, а только целочисленный. :)

Не, настоящие пишут
enum struct timestamp : std::time_t

Настоящие сварщики на C++20 пишут std::chrono::local_seconds, ещё чтобы и без таймзон.

Sign up to leave a comment.

Articles