Обновить

Комментарии 51

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

// такие же определения Processable, а также DataA, DataB и реализаций Processable для них

fn dynamic_processing(item: &dyn Processable) {
    item.process();
}

let a = DataA;
let b = DataB;

dynamic_processing(&a); // Выведет "DataA обрабатывается"
dynamic_processing(&b); // Выведет "DataB обрабатывается"

Причём за счёт deref coercion приводить ссылки явно к &dyn Processable на вызывающей стороне не требуется.

Спасибо за поправку, Вы абсолютно правы! Я использовал Box<dyn Processable>, чтобы показать саму идею динамической диспетчеризации и владения объектом — иногда это нужно, когда надо передать объект с динамическим трейт-типом. Но для большинства случаев и правда лучше обойтись ссылкой без аллокации в куче.

Ваш вариант с &dyn Processable проще и экономичнее, так что спасибо за напоминание — добавлю его в статью как способ оптимизации!

unwrap() - это assert(). С таким отношением становится совершенно понятно, где его применять, а где нет.

Ещё можно считать unwrap аналогом unsafe кода. Есть места, где без него не обойтись и в таких случаях принято оставлять комментарий.

// SAFETY: poisoned lock is a fatal error
let value = mutex.lock().unwrap();

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

Взятие элемента можно сделать через if let Some(v) {} или match. А так, да, иногда бывают случаи, где unwrap никогда не сломает программу.

А через год кто-то поменял код и теперь проверяется не всегда. И вроде скомпилировалось. Но есть нюанс…

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

Почему тогда хотя бы не expect? Тогда комментарий был бы как для читающего комментарии, так и логи.

Я бы выделил два основных применения:

  • Прототипирование кода, когда нужно получить быстрее прототип, который отрабатывает успешный путь, а на всем остальном паникует.

  • Когда ты точно знаешь, что всегда будет значение и unwrap никогда не запаникует

Какие-то совсем уж младенческие ошибки, чесслово. Даже без Rust их можно получить, описаные концепции даже в Java завезли сто лет назад. Вы владение, лайфтаймы пощупайте. А когда доберетесь до higher rank trait boundaries хотя бы, уже будет прям сильно интересней.

а где async и tokio? Там очень прилично моно наступить на разное.

unwarp() вообще нельзя в прод. коде

expect() - тупо бросает панику, еще хуже!

а, где использование "?" ?

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

А с expect() можно добавлять кастомные сообщения об ошибках.

Но если ошибка вполне ожидаема и требует обработки, то ? или Result конечно подходят лучше.

Советую прочесть эту статью, которая раскрывает эту проблему https://blog.burntsushi.net/unwrap/

unwrap() - "когда данные, гарантированно корректны", только бывает это очень! очень редко.

expect() - это паника, с сообщением или нет. Может, вполне нормально, для дебага, о чем и говорится в статье, но в релиз сборке предпочтительнее программу заканчивать с exit code.

Это бывает не редко, а постоянно. Например, итерация от 0 до vec.len(), все же используют vec[i] для доступа к элементу вектора, а это неявный unwrap, писать же .get(i).expect("длина достаточна, я это проверил строчкой выше") никому в голову не придёт.

Постоянно будет, если постоянно так делать. Есть итератор:

for e in vecc { }

и нет unwrap(). Но, если вдруг нужен и индекс, и элемент, сделайте инкремент отдельно.

 в прод. коде

... все мои "претензии" только к прод. коду! Экспериментальный код можно писать как угодно.

А зачем делать инкремент отдельно, если у итератора есть метод enumerate, который вернет пару индекс и элемент?

Вы ещё скажите что unsafe нельзя. А потом загляните в stdlib и tokio под капот.

Вы unwarp и unsafe не различает?

Стремитесь к лучшему, а не вот это все. Если по существу кратко, то unsafe, нет альтернативы в языке, когда unwrap, вполне заменяется на '?'

вполне заменяется на '?'

Заменяется только в двух случаях - если функция возвращает Result или Option.

Во всех других - нет. Так что иметь unwrap() в определённых случаях - нормально. См. код stdlib и tokio выше по ссылкам.

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

Лучше переписать пример с использованием типажа ToString:

//fn process(data: &str) {
fn process<T: ToString>(data: T) {
    let data = data.to_string();
}

Что-то мне хочется не согласиться. В каких случаях это причинит боль? Мне кажется любая функция, которой не нужно владеть строкой, вполне может принимать &str.

ToString это хороший подход, но он применим в основном в апи, чтобы уюзеру было удобно, а внутри кода крейта обычно что-то одно передаёшь, или &str, или String.

P.S. Если вы ожидаете, что в функцию можно передавать только &str или String, то лучше вместо ToString использовать Into<String>.
Во-первых, Into<String> будет эффективнее.
Во-вторых, с ToString можно напороться на то что в функцию можно передать тот же i32.

Во-первых, Into<String> будет эффективнее.

Это почему?

Если на String вызвать Into<String>, то по-сути никакой работы сделано не будет: внутри вызовется From<String> на String, который вернёт эту же самую строку, ничего с ней не сделав.
Если на String вызвать ToString, то там внутри будет клонирование, т.е. будет выделение памяти на куче (потому что ToString принимает &self, в отличии от Into<String>).
А вот вызовы ToString и Into<String> на &str будут идентичными.
То есть Into<String> позволит передавать владение String, там где есть такая возможность.

&str — это как раз оптимальный выбор для аргументов, когда нам не нужна лишняя аллокация. &str — легковесная ссылка, работает быстрее и без лишнего копирования данных, так что для большинства задач это именно то, что нужно. Rust вообще сделан так, чтобы по возможности избегать лишних аллокаций, и &str здесь идеально вписывается.

А вотToString можно использовать его для некой гибкости, но он сразу вызывает to_string(), а значит, создает новую строку. А для проекта, где скорость к примеру критична, это не лучший вариант — если нужна просто ссылка, то лишняя аллокация с ToString и to_string() добавляет ненужную нагрузку

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

Кстати, для получения невладеющей строки также используют AsRef<str>.

Упомянутое в статье требование в шаблонном коде указывать типажи для аргументов шаблонов - типов, на мой взгляд, является наиболее существенным недостатком языка. Это добавляет местами многословности там, где без неё можно было бы обойтись. В C++ с этим проще - наличие методов и допустимость операций проверяется при инстанцировании шаблона, если чего-то не хватает, то будет порождена ошибка.

Да, в Rust надо прописывать типажи для шаблонов, зато сразу ясно, что и где можно вызывать, и без сюрпризиков на этапе компиляции. В C++ свободы больше, но и баги могут всплыть неожиданно (по моему опыту).

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

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

fn mul<
    V: std::ops::DerefMut<Target = T>,
    T: std::ops::MulAssign<i32>
>(
    mut val: V
) {
    *val *= 2;
}

fn foo<
    I: std::iter::Iterator<Item = (K, V)>,
    K,
    V: std::ops::DerefMut,
    F: Fn(V)
>(
    iter: I, cb: F
) {
    for (_, val) in iter {
        cb(val);
    }
}

fn main() {
[...]
    foo(some_map.iter_mut(), mul);
[...]
}

Ведь в параметрах foo() бы тоже тогда пришлось указывать все эти MulAssign и вообще все что мог бы сделать коллбэк внутри себя, а ведь это в общем случае не может быть заранее известно, верно? Но это в свою очередь приводит к невозможности написания кода наподобие такого (по крайней мере в лоб без всяких дополнительных ухищрений):

use std::fmt::Debug;

fn test<
    T: Debug,
    F: Fn(fn(T) -> T) -> T
>(
    gen: F
) {
    fn input<I: Debug>(x: I) -> I {
        x
    }
    
    println!("{:?}", gen(input));
}

fn main() {
    test(|input| {
        input(10);
        input(10.1)
    });
}

На момент второго вызова input он уже мономорфировал, бгг, и кирдык. Для сравнения, в C++ аналогичный код работает сразу без лишних приседаний.

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

И нет, тут вы упёрлись вовсе не в ограничения мономорфизации, а в ограничения трейта Fn. Ну и в сложности с полиморфизмом высших рангов в Rust - полиморфизм, определённо, есть, а вот короткого синтаксиса для него нет.

Проще всего сделать input не функцией, а структурой известного типа.

В примерах к статье можно сделать вид, что у тебя нет шаблонных функций, язык позволяет выразить это через impl:

fn do_something(value: impl Processable) {
 value.process();
 }

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

В плюсах это будет какой-то невнятный аналог define

Разве хвостовых оптимизаций рекурсии в Раст нет? Может, моя память мне изменяет, там даже оптимизацию хвостовых вызовов запилили. С какими-то ограничениями, правда...

Rust не гарантирует TCO. Хотя для некоторых простых случаев, как с Fibonacci, оптимизации компилятора могут помочь, но это скорее редкий бонус, чем прям специфика языка.

В Rust есть обходные пути, тот же crate tailcall, который позволяет добавлять аннотацию хвостовой рекурсии через trampoline, но это больше костыль, чем полноценная функция языка. Также иногда возможны оптимизации через LLVM.

Основное, что важно понимать: автоматической TCO в Rust нет, и полагаться на неё без явных проверок не стоит

Многое можно было бы озаглавить: "Сами создаем проблемы и героически их преодолеваем":

Злоупотребление unwrap() и expect()

Игнорирование ошибок с помощью let _ =

Клонирование всего и вся

Бесконечные рекурсии без хвостовой оптимизации

Использование глобальных переменных с static mut

...а остальное: "Невероятные открытия в мире языков со статической типизацией":

Нельзя реализовать Processable с изменяемым поведением только для data1, а для data2 сделать что-то другое.

Это называется monkey patching и, вообще говоря, не особо приветствуется даже при использовании языков, которые такое позволяют. Добиться необходимого эффекта можно другими путями и они будут более очевидными для читающего/отлаживающего, чем сабж.

Отсутствие ограничений в обобщениях

Решение этой проблемы подскажет даже сам компилятор, такая себе грабля.

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

Шок-контент: динамическое связывание работает через ссылки. Даже в "языках с наследованием". Попробуйте провернуть подобное в C++ и окажется, что объявлять параметры функции с типом абстрактного класса там тоже нельзя. А если в языке есть GC, то обычно с ним в комплекте идет такое понятие, как ссылочные типы (reference types), что как бы намекает на то, что с ними вы оперируете через ссылки, а компилятор сам выполняет boxing.

Ну нет, monkey patching - это другое.

это супер! Но вот эти милые unwrap() и expect() — это как оружие: можно по-умному, а можно себе же в ногу. В большинстве случаев такая привычка возникает, когда мы торопимся. А потом, на продакшене — бах!

Согласитесь, было бы грустно, если бы софт написанный на ориентированном на надежность Rust валился в проде из-за где-то кем-то случайно забытого unwrap(). Поэтому для случаев когда нужно обеспечить то, чтобы Ваш код не упал в production из-за подобного конфуза предусмотрен специальный механизм под названием std::panic::catch_unwind().

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

Однако преднамеренно оставлять unwrap()-ы для обработки своих ошибок в production-коде действительно не стоит, есть и более идиоматичные способы - как, например, упомянутые Вами.

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

Зачем в обычном статическом счетчике использовать Orgering::SeqCst когда там всегда достаточно Ordering::Relaxed?

Добавлю прекрасную книгу про атомики и Rust.

https://marabos.nl/atomics/

Здесь process() реализован для типа Data и будет одинаково работать для всех экземпляров Data. Нельзя реализовать Processable с изменяемым поведением только для data1, а для data2 сделать что-то другое.

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

Манки-патчинг тут ни при чём, при манки-патчинге новую версию метода получают как раз все объекты, а не только избранный.

А делали так, к примеру, в JavaScript. Так сказать, события для бедных: зачем использовать сложный паттерн подписки на событие, если можно просто вызвать метод-заглушку, а кому надо - тот её переопределит.

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

Манки-патчинг тут ни при чём, при манки-патчинге новую версию метода получают как раз все объекты, а не только избранный.

Мне казалось, что манки патчинг это когда для конкретного инстанса переопределяется поведение. Типа:

class IFoo {
  process () => {} // по умолчанию пустой
}
// определяем поведение в наследнике интерфейса
class Bar derive IFoo {
  process =  () => {
    super().process(); // вызываем родителя по необходимости
    // дописываем недостающее 
  }
}

process_special = () => {
  // какой-то особенный process для объекта типа Bar
};
call_processing = (object /*IFoo*/) => {
    object.process()
}
var data1 = new IFoo();
var data2 = new Bar();
var data3 = new Bar();
if (condition) 
  data3["process"] = process_special; // патчим унаследованный метод на инстансе

call_processing(data1); // дефолтная имплементация для интерфейса
call_processing(data2); // вызов переопределенного метода
call_processing(data3); // вызов пропатченного метода если condition

Зря казалось.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Информация

Сайт
beget.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия