Pull to refresh

Comments 27

UFO just landed and posted this here

Сейчас в команде 2 человека, которые научились писать на Rust. Пока что проектов, в которых целесообразно использовать Rust, не так много.

Что-то у вас на вот этой картинке что слева, что справа код ужасен:



На Rust при любой ошибке код упадёт в панику, на Go уйдёт в холостой бесконечный цикл, пожирающий 100% ядра. А то и больше, если process_conn в таком же духе написана.

Целью данного примера было показать схожесть подходов работы с асинхронными операциями. При реальном использовании действительно необходима обработка ошибок.

Если слева добавить "вопросики", а справа — if err != nil return err — схожесть всё ещё будет видна, но код перестанет быть ужасным. А ещё примеры станут и правда эквивалентными.

существующие Lua-модули зачастую несовместимы с Tarantool — например, для работы с сетью и прочими асинхронными операциями

А существующие Rust-библиотеки для сети и асинхронщины (которые tokio и async-std) с Tarantool совместимы? В тарантуле, вроде бы, есть какой-то свой рантайм для этого, неужели Раст с ним может интегрироваться? А если не может - нет проблем при взаимодействии между асинхронным раст-кодом и остальным тарантулом?

tokio и async-std в вопросе взаимодействия с io операционной системы то несовместимы. Нельзя взять TcpSocket или даже таймер из одного рантайма и заюзать в другом. А ещё есть tokio-uring, там довольно сложно придумать нечто стандартное и универсальное, не хватает уровня абстракций в языке.

я сделал поверх растового API тарантула свой пакет для задач взаимодействия между асинхронным кодом и потоком тарантула.. если интересно можно демку посмотреть тут
https://github.com/chertov/tarantool-rpc

Теоретически можно написать executor, который будет работать поверх тарантульного event loop и файберов. Тогда будет возможно использовать async/await поверх тарантульного рантайма. Но это все равно будет несовместимо с большинством асинхронных библиотек, так как они, как правило, привязаны к Tokio или async-std. Возможно, в будущем, когда интерфейсы к reactor добавят в стандартную библиотеку, это будет иметь больше смысла.

Другой подход - запустить Tokio или async-std в отдельным потоке, а с потоком тарантула общаться через передачу сообщений. Попытка сделать библиотеку для удобного исполнения кода в потоке тарантула c интеграцией с Tokio: https://github.com/oleggator/xtm_rust

Интересная статья, спасибо.
в Rust вместе с Tokio можно создать каналы
… а можно и без Tokio, каналы есть как в std (std::sync::mpsc), так и отдельно (crossbeam::channel, например), и Tokio, как правило, непричастен.

Да, но они не приспособлены для использования с асинхронным рантаймом. Необходимо самостоятельно обрабатывать ситуацию переполнения. Либо блокировать поток при использовании блокирующих методов (send у std::sync::mpsc::SyncSender).

Всё хорошо, спасибо. Но мне непонятно лишь одно. Вы давным-давно рекламировали lua, как очень хороший язык для хранимых процедур. Куча примеров была написана. Сейчас я вижу, что он медленный, но кроме как измерений RPS, я ничего не нашёл.

Скажите, у вас есть описание и применение профайлеров кода lua на Tarantool'е? Почему ваш код примера оказался таким медленным? В Rust и go я могу попрофилировать, чтобы это выяснить.

Не могли бы вы измерить и дописать различие в производительности?

Спасибо за статью. Оставлю свое скромное мнение про Rust.)

Разработчики заложили в язык бомбу замедленного действия в виде Traits. К сожалению бесконтрольное применение
Traits приводит к тому, что разработчики библиотек решая простейшие задачи стремятся по максимуму использоваться всесь потенциал Traits из-за этого интерфейс библиотек многократно переусложнен относительно решаемой задачи. Такая же проблема при разработке проектов. Когда смотришь на библиотеку на Си её исходный код прост и понятен. Когда изучаешь библиотеку на Rust тратишь на от х2 до х10 усилий более, существенно повышается количество wft/мин. ))
Тут дело не в том что я не понимаю generic код, а скорее в том что там где нужно передать массив байт и вернуть флаг ок/err, нет необходимости писать N дополнительных Traits.
Мне кажется нас ещё ждет язык с разумным балансом между с одной стороны откровенным примитивизмом go и переусложенным в Rust.

Второй момент который не понравился, это работа с ошибками.
Казалось бы Option/Result благое намерение но большинство кода , что я видел, выглядит как бесконечное unwrap(), это конечно лучше чем go в котором код на 50% состоит из if err != nil { panic(..)/return err }. Но все же непонятно зачем делать фичу которую большинство разработчиков не будут использовать. Тем более что есть богатый опыт других языков включающих это, и там проблемы аналогичные.
Касаемо panic. Не мог понять в go, так и в rust не могу зачем делать такую неудобную реализацию обратоки ошибок, когда уже сущесвует подход с try/catch, чем паника отличается по сути от exception кроме существенно менее удобного синстаксиса работы с ней, может кто ни будь пояснит?

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

Касаемо panic. Не мог понять в go, так и в rust не могу зачем делать такую неудобную реализацию обратоки ошибок, когда уже сущесвует подход с try/catch, чем паника отличается по сути от exception кроме существенно менее удобного синстаксиса работы с ней, может кто ни будь пояснит?

try/catch — это goto на стероидах, но со всеми недостатками goto. Поэтому его и нет в расте.

Паника и не проектировалось как что-то удобное, это просто средство аварийного снятия потока/программы.

Существенно более удобный синтаксис у Option/Result, тут сразу "?" и другие плюшки.

Вам нужно покопать в этом направлении, посмотреть хороший чужой код, посмотреть на библиотеки anyhow/thiserror и подобные, поизучать методы, которые есть у Option/Result, например unwrap_or_else(), map() и другие, они очень удобны. collect() работает для Option/Result.

И смириться с тем, что unwrap() — это дебажный костыль и плохой код. Если кто-то запускает в продакшен код с unwrap() там, где может быть Err/None — он совершает ошибку.

Спасибо за ответ.

Касаемо try/catch могли бы разъяснить - почему это goto на стероида и если это так то чем он по сути отличается от паники.

Что касаемо Option/Result имею богатый опыт работы с подобными структурами 1.5 года коммерческой работы на Scala, различные pet проекты даже не буду упоминать. Более чем хорошо понимаю данные монады и как с ними работать.

На мой взгляд большинство программистов пришет unwrap потому что понимает - данные в норме, в этом месте, должны быть, а если их нет значит программа в некорректом состоянии, логичто в этом случае бросить панику(и т.п.), вторая причина - если все писать через map и т.п. то код очень сильно увеличивается в размере, пишется намного дольше, становиться сложнее для восприяти и рефакторинга. Это моя субьективная оценка, но имеются и обоснования, т.к. каждая из этих конструкций приносит дополнительную сложность в код, значит и общая сложность кода увеличивается. Становится ли он от этого более надежным, спорный момент лично я не обнаружил доказаться что это так.

Это все мне очень напоминает Scala, когда появилась был такой подъем энтузиазма. А когда начали применять в проде, оказалось что проекты пишутся в несколько раз дольше, падают также часто как на Java. Специалистов найти сложно потому что не многим по душе сражаться с типами.)

От себя добавлю, что основные трудности try/catch — это развёртывание стека при возникновении исключения. Вам же надо как-то пробросить исключение до первого попавшегося catch и сообщить в нём, что в таком-то месте (метод/строка) возникло исключение и показать все предыдущие исключения в этой цепочке.
Когда мы говорим про код, который может быть встроен (inline) или как-либо оптимизирован то для того, чтобы эту информацию не потерять, мы должны как-то это всё предвидеть на стадии компиляции.
Мой текст — это не точное изложение, но приблизительный пересказ подобных дискуссий, почему так, а не так :)

Касаемо try/catch могли бы разъяснить - почему это goto на стероида и если это так то чем он по сути отличается от паники.

На всякий случай еще раз поторю, что паника — это не средство обработки ошибок.

Сам писать не хочу, просто скопипащу у гугла, я тут со всем согласен.

  • When you add a throw statement to an existing function, you must examine all of its transitive callers. Either they must make at least the basic exception safety guarantee, or they must never catch the exception and be happy with the program terminating as a result. For instance, if f() calls g() calls h(), and h throws an exception that f catches, g has to be careful or it may not clean up properly.

  • More generally, exceptions make the control flow of programs difficult to evaluate by looking at code: functions may return in places you don't expect. This causes maintainability and debugging difficulties. You can minimize this cost via some rules on how and where exceptions can be used, but at the cost of more that a developer needs to know and understand.

  • Exception safety requires both RAII and different coding practices. Lots of supporting machinery is needed to make writing correct exception-safe code easy. Further, to avoid requiring readers to understand the entire call graph, exception-safe code must isolate logic that writes to persistent state into a "commit" phase. This will have both benefits and costs (perhaps where you're forced to obfuscate code to isolate the commit). Allowing exceptions would force us to always pay those costs even when they're not worth it.

  • Turning on exceptions adds data to each binary produced, increasing compile time (probably slightly) and possibly increasing address space pressure.

  • The availability of exceptions may encourage developers to throw them when they are not appropriate or recover from them when it's not safe to do so. For example, invalid user input should not cause exceptions to be thrown. We would need to make the style guide even longer to document these restrictions!

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

Вы фактически говорите, что программистам лень писать код обработки ошибок и проще упасть. Это действительно так.

Но я не хочу быть таким разработчиком. И большинство раст-разработчиков тоже не хочет.

Если разработчику лень заниматься обработкой ошибок, то раст — неверный выбор. Есть питон, есть с++, где можно нулевой указатель разыменовывать, а операционная система все обработает. Очень удобно, нет лишнего кода, который

становиться сложнее для восприяти и рефакторинга

На мой взгляд большинство программистов пришет unwrap потому что понимает — данные в норме, в этом месте, должны быть, а если их нет значит программа в некорректом состоянии, логичто в этом случае бросить панику

Если именно так, то это как раз нормальное применение паники. Если мы не можем (удобно) выразить в типах необходимые ограничения и программа оказалась в разломаном состоянии, то это баг и сделать с этим мало что можно. В идеале правда использовать не unwrap, аexpect или добавить комментарий объясняющий, что происходит.


вторая причина — если все писать через map и т.п. то код очень сильно увеличивается в размере, пишется намного дольше, становиться сложнее для восприяти и рефакторинга.

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


Впрочем, с исключениями код действительно получается короче, но надо понимать, что это и преимущество и недостаток.

Интересные вопросы.
Постараюсь ответить по пунктам:

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

Не совсем понимаю, какой вред носит реализация тех или иных трейтов библиотечным типом. Если некоторый тип T реализует трейт Foo, это влияет только дополняет то, что с ним можно делать, но никак -- по-моему -- не усложняет взаимодействие с ним. Если этот типаж Вам, как пользователю API, не нужен, то вы об этом даже и не задумываетесь. Если же он окажется нужным, то вам же будет плюсом то, что он реализован для данного типа.
Как по мне, в C++, как раз, с этим хуже, потому что многие решения (точнее, все), завязанные на активном использовании статического полиморфизма, полагаются сугубо на около-утиную типизацию (утрированно), а именно наличие функций/полей/.., "похожих" на то, что ожидается. Тут и не получается получать реальные статические гарантии того, что это именно то, что нужно (например, у типа могут быть begin() и end()методы, но при этом он может не иметь никакого отношения к итераторам), и, при этом, нельзя реализовать полноценную совместимость между библиотеками, в случае, например, коллизий каких-либо имён (условно, libFoo требует метод ::std::string name(), а libBar -- Id name(); пример искуственный но суть, думаю, передаёт).
Более того, многие опциональные реализации трейтов (например, для поддержки популярных, но не обязательных библиотек) во многих проектах спрятаны за feature-флагами, так что, если они вам не нужны, вы и не будете их включать.

Казалось бы Option/Result благое намерение но большинство кода , что я видел, выглядит как бесконечное unwrap()

Довольно странно. Не буду бросаться утверждениями, но, как мне кажется, то, что вы видели, это либо те случаи, когда известно, что ошибка невозможна, но, в общем случае, возвразается Result/Option (например, 127.0.0.1"::parse::<IpAddr>()), либо вы натыкались на неидеоматично написанный код.

Но все же непонятно зачем делать фичу которую большинство разработчиков не будут использовать

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

Более того, существует прекрасный try-operator -- ?, который по смыслу (с некоторыми допущениями) превращает

fn foo() -> Result<String, BusinessError> {
  let id = bar()?; // <- туть

  todo!("...")
}

fn bar() -> Result<u32, IoError> {
  todo!("...")
}

в

fn foo() -> Result<String, BusinessError> {
  let id = match bar() { // то, во что по смыслу превращается оператор
		Ok(id) => id,
    Err(ioError) => return BusinessError::from(ioError);
  };

  todo!("...")
}

fn bar() -> Result<u32, IoError> {
  todo!("...")
}

За счёт этого, в большинстве случаев, проброс ошибки вверх по стеку -- это просто написание оператора-вопросика после нужного значения.

Касаемо panic. Не мог понять в go, так и в rust не могу зачем делать такую неудобную реализацию обратоки ошибок, когда уже сущесвует подход с try/catch, чем паника отличается по сути от exception кроме существенно менее удобного синстаксиса работы с ней, может кто ни будь пояснит?

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

Утрируя, мы ведь не пишем код вроде (псевдокод):

true doOperation() throws false { ... }

Потому что как true, так и false оба ожидаемые результаты вызова функции. Так и, для большинства сценариев, ошибочный результат -- это тоже результат, а не какой-то отдельный исключительный сценарий.

Более того, этот подход ещё и дешевле, потому что не приходится поддерживать инфраструктуру, связанную с заполнением стек-трейса и прочими вещами, которые в большинстве случаев не нужны: с болью привожу как антипример многое из API джавы, где есть Integer#parseInt(String), который преобразует строку к числу, но, если строка не число -- кидает исключение, хотя во многих сценариях разработчик ожидает, что пользователь введёт не число и ему нет смысла от полноценно созданного NumberFormatException с заполненным стек-стрейсом (что очень дорого), который он тут же хочет обработать:

int number;
try {
  number = Integer.parseInt(userInput);
} catch (final NumberFormatException expected) {
  number = 42; // `expected` при этом даже не используется
}

Аналогичный код на Расте:

let number = userInput.parse::<i32>().unwrap_or(42);
// здесь небольшое уточнение:
// `unwrap_or` не имеет никакого отношения к `unwrap`,
// а подобен джавовому Optional#orElse

И таких сценариев большинство.

Механизм же паник предназнчен именно для того, чтобы безопасно (точнее, корректно) сообщить о том, что какой-то относительно хлипкий инвариант был нарушен, но это не имеет какого-то отношения к логике программы и, скорее всего, не подразумевает дальнейшей обработки. Например, есть std::unreachable!, который нужен для того, чтобы помечать недостижимый (по мнению разработчика) код, который, в случае, если до него таки дошло исполнение, паникует. Обрабатывать панику тут, в оббщем случае, нет никакого смысла, потому что её первопричина -- логическая ошибка в коде самого разработчика.

Это такой аналог unchecked exceptions из той же Джавы.

Надеюсь, что смог ответить на волпросы :)

Добрый день проблема не в самой концепции тэйтов, а в том что разработчики её используют без всякой меры. Эту проблему вижу в каждом проекте. Trait'ы инструмент для специфических случаев, но типичный разработчик приступая к решению очередной задачи думает не как решить её наиболее простым и очевидным для других способом, а как по максимому использовать все возможности предоставляемые Tatait'ами. Как итог существенное усложнение кода.

По этому я и написал что "Разработчики заложили в язык бомбу".

Все это я уже видел на примере Scala в итоге бизнес отказывается от языка. Потому что при аналогичном с другими ЯП результате, издержек много - проект сложнее, кадры найти трудно, фичи выкатываются дольше.

Касаемо Option/Result, все что вы написали справедливо. Но дело в другом. Проще написать try/catch нужном выше по стэку и перехватить любу ошибку снизу, не проверяя пробросили вы Option на верх или где то забыли. Бесплатно получить трассировку стэка с подробной ифнормацией об ошибке. Все это за вас делает компилятор. Пока я не видел проблем при преминении концепции try/catch, код работает стабильно на всех языка что писал. А поскольку try/catch требует существенно меньше усилий от программиста, я предпочитаю для себя его, а оставшиеся силы направляю на решение прикладных вопросов.

Мой подход заключается в том что бы написать максимально простой и компактный код, который работает только в определнном состоянии а если где то возникает не ожидаемая ситуация то завершить выполнение проблемной ветки кода аварийно. И потом разбираться почему так произошло. Как результат ПО работает стабильно, понятно и быстро пишется. Свободное время я больше на решение бизнес задачи или на себя.)

проблема не в самой концепции тэйтов, а в том что разработчики её используют без всякой меры.

Это можно сказать и про макросы (и в расте и вообще) и про шаблоны в С++ и практически про любой языковый механизм. Ту же перегрузку операторов можно интересно использовать давая новый смысл привычным операциям. А уж если можно свои новые операторы вводить, то и тем более. Сложившиеся в языке/инфраструктуре практики действительно влияют на "средний" код, но я бы не сказал, что в расте ситуация сильно выделяется. Особенно если сравнивать со схожими языками: С++ или той же скалой, а не с С или Go. По моим ощущениям, макросами (которые читать куда сложнее, чем обобщённый код с трейтами) стараются без необходимости не увлекаться.


Эту проблему вижу в каждом проекте.

Мне кажется, что это сильное преувеличение. Опять же, как с этим бороться? Сделать язык заранее примитивным? У этого есть обратная сторона в виде многословности и сложности введения абстракций.


не видел проблем при преминении концепции try/catch, код работает стабильно на всех языка что писал

Не очень понимаю, что это значит. Код будет работать так как написан. Но писать с исключениями действительно проще и быстрее. Отлаживаться — дольше. Если, в первую очередь, критична скорость реализации, код пишется рассчитывая на на "happy path", а try/catch втыкается уже по результатам тестирования, то да раст — не лучший выбор.

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

Я так понимаю, что претензия не именно к трейтам, а к дженерикам в общем. В целом это вопрос достаточно филосовский и переусложнённый обобщённый код действительно попадается. Но что поделать если нужна гибкость или скорость?


Возьмём самый банальный пример: функция, которая принимает путь. Мы можем принимать Path, PathBuf, строку и т.д. Если выберем что-то конкретное, то код будет проще, но пользоваться им будет менее удобно. Приходится делать функцию обобщённой (AsRef<Path>). Код стал (немного) сложнее, зато пользоваться им проще. Как по мне, так это вполне себе выигрыш. Как сделать лучше, если выкинуть "бомбу" в виде трейтов?


Если речь о том, что шаблоны в С++ не требуют указания трейтов, то есть "как бы проще", то тоже не согласен. Во первых, в С++ идут (или даже пришли) к явным ограничениям (концепты). И даже в Go, который славился простотой, добавляют дженерики.


Казалось бы Option/Result благое намерение но большинство кода, что я видел, выглядит как бесконечное unwrap()

Можно несколько примеров из популярных библиотек? Так чтобы были именно "бесконечные анврапы".

Мне кажется дженерики и трейты, как они реализованы в расте, вообще очень удобны.

Конкретно тем, что легко позволяют переиспользовать код из core:: языка (даже не std::). Например, те же итераторы. Очень-очень легко сделать свой итератор. Если свой тип реализует трейт Iterator, то с ним сразу начинают работать все стандартные адаптеры, вроде map(), filter() итд. Не нужно руками писать тривиальный код.

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

PathPathBuf, строку и т.д. 

Если вы строку оборачиваете в Path как это решает проблему? Все равно в типе Path будет лежать строка. Т.е. по сути проблема оборачивается, но не решаете. Как вы считаете это так или я допускаю ошибку?

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

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

Использование unwrap это типично для прикладного кода, в данном случае я рассматриваю реальную практику применения которую наблюдаю. Поэтому и вызывает сомнение польза такой фичи. Это то же самое что доказывать - в Си есть free а значит проблем с утечкой памяти там быть не должно, а то что разработки забывают или забивают на free проблема конкретного проекта.

Если вы строку оборачиваете в Path как это решает проблему?

Возможно, я плохо объяснил, попробую ещё раз. У функции такой контракт — она принимает путь:


fn foo(p: &Path) { ... }

Если я, как пользователь библиотеки, хочу "захардкодить" путь, то и хотелось бы просто написать foo("some/path"), а не foo(&Path::new("some/path")). Можно, конечно, изменить функцию — принимать строку, немного пожертвовав наглядностью, а то, что это путь — написать в документации. Но тогда если у меня как раз есть Path или даже скорее PathBuf, который получен после каких-то манипуляций, то мне уже будет менее удобно пользоваться новой функцией. И тут на помощь приходят дженерики (с трейтами): AsRef<Path> — это как раз способ сказать "функция принимает любой тип, который преобразовывается к пути" (смысл у AsRef чуть-чуть другой, но сейчас это не важно). И эта функция будет автоматически работать и со строками и со строковыми слайсами и OsStrOsString) и т.д.


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

Может, конечно, у меня глаз замылился. Хотя до раста продолжительное время писал на С++, где как раз чаще используются исключения. Тем не менее, принятому в расте подходу я очень рад.


Используя try/catch код каждой функции можно сократить.

Можно, если в данном месте ошибку обрабатывать не нужно. Если нужно, то как раз наоборот. Да, чаще ошибка просто прокидывается на уровень выше. Тем не менее, с сахаром в виде ? код не сильно проигрывает в плане многословности, зато места, где может произойти ошибка, сразу видны, а не как с исключениями, где потенциально оно может из любого места прилететь. Опять же, в моём коде к ошибкам практически на каждом уровне добавляется контекст (с использованием библиотеки anyhow), что в итоге получается удобнее, чем просто стек вызовов.


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

Не могу согласиться.

хорошая статья, но не хватает ссылки на проект

Sign up to leave a comment.