Tarantool, как известно, поддерживает любой язык, который совместим с C и компилируется в машинный код. В том числе есть возможность реализации хранимых функций и модулей на Lua и C. Тем не менее, уже в двух своих проектах мы использовали Rust (в одном из них полностью перенесли Lua-код на Rust) и получили 5-кратное увеличение производительности по сравнению с Lua и сопоставимый результат, который дает по производительности C.

Меня зовут Олег Уткин и в Tarantool я занимаюсь высоконагруженными системами хранения данных. Я расскажу про упомянутые два проекта, а также о том, чем так хорош Rust, в котором уже давно существуют различные биндинги для API Tarantool и написания Lua-модулей. Например, вы можете прямо сейчас взять Rust и написать код под Tarantool, в том числе, хранимые процедуры и сторонние модули, которые можно использовать без Lua. Интересно? Поехали!

Казалось бы, Lua или C — неплохие языки. Если бы не их существенные недостатки. Например, хоть Lua и позволяет быстро разрабатывать код, но в некоторых ситуациях он бывает недостаточно быстрым.

И, откровенно говоря, у него не очень хорошая экосистема, просто потому, что обычно этот язык встраивают в приложения, чтобы пользователь мог расширить их функционал и написать свой код. Это приводит к тому, что существующие Lua-модули зачастую несовместимы с Tarantool — например, для работы с сетью и прочими асинхронными операциями. А код, написанный для одного окружения, допустим, OpenResty на Nginx, вы не сможете запустить на Tarantool или на чистом Lua-интерпретаторе. Из-за этого получаются немного изолированные экосистемы.

Если говорить о С, то код быстро исполняется, но его достаточно тяжело писать из-за ручного управления памятью. Что может вызывать различные баги в работе с ней и замедлять время отладки кода. Кроме того, у него достаточно сложный интерфейс для написания Lua-модулей и бывает трудно интегрироваться со сторонними библиотеками. Во-первых, из-за огромного зоопарка систем сборки, которые для использования приходится интегрировать со своим кодом. А, во-вторых, из-за отличий подходов, например, к работе с сетью — подходы приходится «женить».

Поэтому я составил список того, чего бы я хотел от языка:

  • Богатая экосистема пакетов, которые можно переиспользовать, экономя время на разработку, отладку и тестирование.

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

  • Удобная система сборки, которая будет все это собирать, линковать и упрощать интеграцию с другими библиотеками.

  • Относительно быстрая скорость разработки.

  • Безопасная работа с памятью.

  • Скорость исполнения.

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

Кейс: разработка хранимых процедур для Tarantool

Это простейшая хранимая процедура, которая принимает три параметра: год, квартал и минимальную стоимость:

Код на Lua
function some_procedure(year, quarter, min_cost)
    local space = box.space.some_space.index.some_index
    local result = space
        :pairs({ year, quarter }, { iterator = 'GE' })
        :take_while(function(record)
            return record.year == year
                and record.quarter == quarter
        end)
        :filter(function(record)
            return record.earning > min_cost
        end)
        :totable()
    return result
end

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

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

Код на C
int some_procedure(box_function_ctx_t* ctx, const char* args, const char* args_end) {
    uint32_t args_n = mp_decode_array(&args);
    assert(args_n == 3);

    uint32_t year = mp_decode_uint(&args);
    uint32_t quarter = mp_decode_uint(&args);
    double min_cost =  mp_decode_double(&args);

    uint32_t space_id = box_space_id_by_name("some_space", strlen("some_space"));
    uint32_t index_id = 0;

    char key_buf[128];
    char *key_end = key_buf;

    key_end = mp_encode_array(key_end, 3);
    key_end = mp_encode_uint(key_end, year);
    key_end = mp_encode_uint(key_end, quarter);
    key_end = mp_encode_double(key_end, min_earnings);

    box_iterator_t* it = box_index_iterator(space_id, index_id, ITER_GE, key_buf, key_end);
    while (1) {
        box_tuple_t* tuple;
        if (box_iterator_next(it, &tuple) != 0) {
            return -1;
        }
        if (tuple == NULL) {
            break;
        }

        uint32_t args_n = mp_decode_array(&tuple);
        assert(args_n == 3);
        uint32_t record_year = mp_decode_uint(&tuple);
        uint32_t record_quarter = mp_decode_uint(&tuple);
        double record_cost =  mp_decode_double(&tuple);

        if (record_year != year || record_quarter != quarter) {
            break;
        }
        if (record_cost <= min_cost) {
            continue;
        }

        box_return_tuple(ctx, tuple);
    }
    box_iterator_free(it);

    return 0;
}

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

Код на Rust
#[derive(Serialize, Deserialize)]
struct Record {
    year: u16,
    quarter: u8,
    earnings: f64,
}

fn some_procedure(ctx: &FunctionCtx, args: FunctionArgs) -> c_int {
    let args_tuple: Tuple = args.into();
    let (year, quarter, min_cost): (u16, u8, f64) = args.as_struct().unwrap();

    let space = Space::find("some_space").index("some_index");

    let result: Vec<Record> = index
        .select(IteratorType::GE, &(year, quarter))
        .map(|tuple| tuple.as_struct::<Record>())
        .take_while(|record| record.year == year && record.quarter == quarter)
        .filter(|record| record.cost >= min_cost)
        .collect()
    match ctx.return_mp(&result) {
        Ok(_) => 0,
        Err(_) => -1,
    }
}

Для написания этого кода мы использовали уже готовые биндинги к Tarantool. По размеру и логике это аналогично Lua, но производительность такая же, как у C. За счет чего это достигается?

Кодогенерация, макросы

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

Вы это даже можете не писать сами. Уже есть много библиотек, которые сделают это за вас. Как правило, они используют фреймворк Serde (Serializer, Deserializer):

Serde
#[derive(Serialize, Deserialize)]
struct Record {
    year: u16,
    quarter: u8,
    cost: f64,
}
fn parse_record(raw_data: &str) -> Result<Record, ParseError> {
    let record: Record = serde_json::from_str(raw_data)?;
    return record;
}

Serde, по сути, реализует универсальный интерфейс для написания библиотек, который будет сериализовать/десериализовать ваши данные во время компиляции. На основе этого кода можно подключить любую библиотеку, которая умеет сериализовать JSON либо MessagePack, и делает это буквально в одну строку.

Функциональное программирование, итераторы

Rust включает в себя различные операции для обработки данных, которые приходят из итераторов. Например, можно cмаппить данные с помощью оператора map, отфильтровать или агрегировать их с reduce:

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

Результаты

Приведу пример теста. На процедуре выборки и на одном инстансе Tarantool (один поток) при 100% утилизации CPU получаются такие результаты:

Видим, что код на Lua дает 340 RPS, а на C — 1700. Rust при этом позволяет написать столько же простого кода как на Lua, но получить производительность сопоставимую с C.

Кейс: разработка модулей

Следующий кейс, в котором мы использовали Rust — это разработка модулей. Это пример, как код на Rust можно обернуть в биндинг, и не важно, Lua это, JS или Python — вы получите готовый модуль для вашего языка программирования. Покажу на простейшем примере:

Есть процедура, написанная на Lua, которая выводит «hello, world». Чтобы реализовать ее на Rust, достаточно соблюсти некоторую сигнатуру функций, которые дальше мы можем пробросить в Lua. При этом никаких преобразований данных делать не нужно. И это касается всех примитивных типов данных, которые есть в Rust: они будут автоматически кодированы в тип, удобный для работы в Lua.

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

Пример Lua-биндингов библиотеки avro_rs
struct Avro { schema: Schema }

impl Avro {
    pub fn new(schema: &str) -> Result<Self, avro_rs::Error> {
        Ok(Avro { schema: Schema::parse_str(schema)? })
    }
  
    pub fn decode<R: Read>(&self, reader: &mut R) -> Result<Value, avro_rs::Error> {
        let avro_value = avro_rs::from_avro_datum(&self.schema, reader, None)?;
        Ok(Value::try_from(avro_value)?)
    }
}

impl mlua::UserData for Avro {
    fn add_methods<'lua, M: LuaUserDataMethods<'lua, Self>>(methods: &mut M) {
        methods.add_method("decode", |lua, this: &Avro, blob: LuaString| {
            let json_value = this.decode(&mut blob.as_bytes())?;
            lua.to_value_with(&json_value)
        });
    }
}

В нашем реальном кейсе была задача декодировать Avro-сообщения с помощью Lua внутри Tarantool. К сожалению, реализации Avro на Lua не существовало, а если бы и существовало, то, скорее всего, она работала бы достаточно медленно. Но у нас уже была готовая библиотека, написанная на Rust. Мы портировали её на Lua, просто написав для нее биндинги.

Конечно, это не все возможные области применения Rust. Говоря в общем, Rust может заменить такие языки, как C и C++, поскольку он так же, как они, компилируется в машинный код. При этом за все возможности языка вы платите только один раз — во время компиляции.

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

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

Чем хорош Rust 

Память

Одна из особенностей Rust — это понятия владения и аффинных типов данных:

Передача владения
struct User {
    name: String,
    country: String,
    age: i32,
}

pub fn main() {
    let user = User {
        name: String::from("Igor"),
        country: String::from("Russia"),
        age: 30,
    };
    print_user_age(user); // здесь происходит перемещение user внутрь функции
    print_user_country(user); // ошибка: используем перемещенный объект
}

fn print_user_age(user: User) {
  println!("{} is {} years old", user.name, user.age);
}

fn print_user_country(user: User) {
  println!("{} lives in {}", user.name, user.country);
}

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

Заимствование

В случае, когда мы не хотим передавать владение какими-то нашими переменными в другие области видимости, мы можем передать их по ссылке:

Заимствование
struct User {
  name: String,
  country: String,
  age: i32,
}

pub fn main() {
		let user = User {
				name: String::from("Igor"),
				country: String::from("Russia"),
				age: 30,
		};

		print_user_age(&user); // передаем по ссылке
		print_user_country(&user); // передаем по ссылке
		// Здесь вызовется деструктор user
}

fn print_user_age(user: &User) {
    println!("{} is {} years old", user.name, user.age);
}

fn print_user_country(user: &User) {
    println!("{} lives in {}", user.name, user.country);
}

Владение переменными, которые мы выделяем в текущем скоупе, остается там же, и там же будет вызван деструктор. Но теоретически может случиться, что у нас появится ссылка, которая была объявлена во внешнем скоупе, а данные, на которые она указывает — выделены в дочернем скоупе.

То есть данные, на которые указывает ссылка, могут быть освобождены до того, как была освобождена ссылка, и в этот момент она укажет на невалидные данные. Здесь на помощь придет Borrow checker (проверщик заимствований), который встроен в компилятор Rust:

Borrow checker
struct User { age: i32 }

pub fn main() {
    let user_ref: &User;
    {
        let user = User { age: 30 };
        user_ref = &user; // ошибка компиляции, т.к. может вызвать невалидную ссылку
        // здесь вызовется деструктор user, делая ссылку user_ref невалидной
    }
    // здесь вызовется деструктор user_ref
}

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

Умные указатели

Но что, если мы не можем определить размер данных во время компиляции? Тут на помощь приходит тип Box. Он позволяет выделять данные на heap. По сути, это умный указатель, похожий на std::unique_ptr в C++. Мы выделяем данные на стеке, сразу перемещаем их на heap, а их поведение остается такое же, как если бы мы эти данные выделили на стеке. Здесь видно, что точно так же мы перемещаем переменную user в вызываемую функцию, и там данные уже освобождаются:

тип Box
struct User {
  name: String,
  country: String,
  age: i32,
}

pub fn main() {
		let user = Box::new(User {
				name: String::from("Igor"),
				country: String::from("Russia"),
				age: 30,
		});
		print_user_age(user); // здесь происходит перемещение user внутрь функции
}

fn print_user_age(user: Box<User>) {
		println!("{} is {} years old", user.name, user.age);
		// Здесь вызовется деструктор user
}

Но бывают ситуации, когда нужно использовать ссылку на одни и те же в несколькими областях видимости или передать в другие потоки исполнения. Для этого в Rust есть тип Rc (reference counter), и это тоже умный указатель. Когда мы выделяем память с помощью Rc, то у нас есть счётчик, который указывает — сколько есть ссылок на конкретно эти данные. Когда мы явно клонируем этот указатель, то счетчик ссылок на эти данные инкрементируется. И декрементируется, если указатель вызывает свой деструктор. Если счётчик становится 0, то данные освобождаются:

тип Rc (reference counter)
struct User {
  name: String,
  country: String,
  age: i32,
}

pub fn main() {
		let user = Rc::new(User {
				name: String::from("Igor"),
				country: String::from("Russia"),
				age: 30,
		});

		let user2 = user.clone(); // клонируем умный указатель
		print_user_age(user2); // здесь происходит перемещение указателя на user внутрь функции
		print_user_country(user); // здесь происходит перемещение user внутрь функции
}

fn print_user_age(user: Rc<User>) {
		println!("{} is {} years old", user.name, user.age);
}

fn print_user_country(user: Rc<User>) {
		println!("{} lives in {}", user.name, user.country);
}

Этот подход называется подсчетом ссылок и используется еще много где. Чем же он хорош? Например, вот графики из достаточно интересной статьи Discord о том, как они с помощью перехода с Go на Rust смогли ускорить свои сервисы: 

Garbage Collection заставляет прервать исполнение кода и начать освобождать неиспользуемую память. Но такие остановки негативно влияют на latency запросов, особенно на 95% и выше. На графике видно, что при использовании Rust удалось значительно сократить latency, а нагрузку на CPU сделать более равномерной.

Unsafe

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

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

let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
		println!("r1 is: {}", *r1);
		println!("r2 is: {}", *r2);
}

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

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

Теперь перейдём к следующей категории. 

Алгебраические типы данных

Алгебраические типы данных пришли в Rust из функционального программирования. По сути, это что-то типа суммы типов, и в некоторых языках их называют тегированные enum. Они позволяют в одном типе хранить сразу несколько вариантов того, как он будет выглядеть и какую структуру иметь:

Enum
enum Shape {
    Square { width: u32, length: u32 },
    Circle { radius: u32 },
    Triangle { side1: u32, side2: u32, side3: u32 },
}

pub fn main() {
    let square = Shape::Square { width: 10, length: 20 };
    let circle = Shape::Circle { radius: 20 };

    let shape: Shape = square;
    match shape {
        Shape::Square { width, length } => println!("square({}, {})", width, length),
        Shape::Circle { radius } => println!("circle({})", radius),
        _ => println!("other shape"),
    }
}

В примере мы выделяем несколько вариантов enum (Square, Circle, Triangle) и присваиваем их общей переменной Shape. Потом, с помощью pattern matching, определяем, что у нас лежит внутри этой переменной, и на основе этого реализовываем какую-то логику.

На основе этого механизма в Rust также организована работа с ошибками:

Проверка на ошибки
enum Result<T, E> {
    Ok(T),
    Err(E),
}

fn do_something_that_might_fail(i: i32) -> Result<f32, String> {
    if i == 42 {
        Ok(13.0)
    } else {
        Err(String::from("this is not the right number")) 
    }
}

fn main() -> Result<(), String> {
		let v = do_something_that_might_fail(42)?;
		println!("found {}", v);
		// эквивалентно
		let result = do_something_that_might_fail(42);
		match result {
				Ok(v) => println!("found {}", v),
				Err(e) => return Err(err),
		}

		Ok(())
}

Как это работает? Из функции возвращается тип Result, который может находиться в двух состояниях: Ok(T), то есть выполнилось с успехом или Err(E), возвращая какую-то ошибку. Мы можем работать с этим явно — с помощью того же pattern matching). Либо используя специальный оператор (знак вопроса), который возвращает то, что вернулось из функции при успехе. Если завершилось с ошибкой, то он передает её дальше в ту функцию, которая вызвала исполнение.

Таким же образом решается проблема с нулевыми указателями. У нас есть переменные, которые могут в себе хранить данные, а могут не хранить. Тут на помощь приходит тип Option, который может находиться в двух состояниях. Это либо None (в текущей переменной нет данных), либо Some(T), которое хранит данные, что мы хотим вернуть:

тип Option
enum Option<T> {
    None,
		Some(T),
}

fn do_something_that_might_fail(i: i32) -> Option<f32> {
		if i == 42 {
				Some(13.0)
		} else {
				None
		}
}

fn main() {
		let result = do_something_that_might_fail(42);
		match result {
				Some(v) => println!("found {}", v),
				None => println!("not found"),
		}
}

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

Traits (типажи)

Ещё один из ключевых механизмов языка — это traits (типажи), что-то вроде интерфейсов или абстрактных классов, которые есть в ООП-языках. Traits позволяют определить желаемое поведение некой структуры или enum, указав, какие методы они должны реализовывать.

Помимо этого, traits позволяют реализовать дефолтные методы, которые, например, основаны на обязательных методах. То есть, реализовав всего парочку обязательных методов, мы можем получить множество методов, основанных на них — и автоматически реализовывать логику для наших структур:

traits (типажи)
trait Animal {
		pub fn get_name(&self);
		pub fn say();
}

struct Dog { name: String };
impl Animal for Dog {
		pub fn get_name(&self) -> String { self.name.clone() }
		pub fn say() { println!("bark"); }
}

struct Cat { name: String };
impl Animal for Cat {
		pub fn get_name(&self) -> String { self.name.clone() }
		pub fn say() { println!("meow"); }
}

fn main() {
		let animal: Animal = Dog{ name: String::from("Rex") };
		animal.say();
		println!("animal's name is {}", animal.get_name());
}

Теперь перейдём к следующей категории.

Экосистема

Пакетный менеджер и система сборки

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

Чем это может быть полезно? Например, при сборке нашего пакета мы можем сразу скомпилировать реализацию Protobuf спецификации. А если мы хотим скомпилировать наш проект на Rust «сишным» кодом, то это можно будет сделать автоматически во время компиляции — просто указав, какие файлы нужно к нему прилинковать.

Сторонние библиотеки

В экосистеме Rust есть огромное количество пакетов, которые могут нам пригодиться на все случаи жизни: от различных сетевых протоколов коннекторов к БД до HTTP-фреймворков. Но если вам и этого недостаточно, то в Rust есть интерфейс для взаимодействия со сторонними языками. Например, вы можете писать код на C и вызывать его в коде на Rust:

Это очень удобно, если у вас уже есть часть кодовой базы, которая написана на C — вы просто можете переписать часть кода на Rust. Либо наоборот, у вас есть небольшой код на Rust, и тогда вы сможете интегрировать его в свой проект.

Стоит заметить, что вызов «сишного» кода по FFI в Rust всегда считается unsafe. Тут программисту нужно быть более аккуратным, чтобы обработать случаи, когда происходит небезопасная работа с памятью. Он должен написать безопасную обертку над небезопасным интерфейсом.

Инструменты отладки

Также из C в Rust пришли различные инструменты для отладки. Например,  Valgrind для поиска утечек, дебаггеры GDB и LLDB, а также профайлеры perf и dtrace. Вот пример трейса через perf простого приложения: 

Видно, что все символы удобно подтягиваются. Также можно смотреть по функциям, кто сколько процессорного времени отъедает.

Документация

У Rust есть специальный сайт, где собрана документация для всех пакетов, которые есть в их registry. Вы можете в своем пакете описать комментарии к вашим функциям, добавив части документов прямо в код — и всё будет отображаться на сайте вместе с остальной документацией по пакетам Rust.

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

И, наконец, по работе с unsafe есть Rustonomicon — подробное руководство, как правильно писать обертки на unsafe-код, которые будут безопасны уже для работы из Rust’ового кода. Это руководство также описывает некоторые внутренности языка, и его полезно почитать.

Для начинающих я бы рекомендовал пройти Rust tour — он показывает все возможности языка, приводя различные примеры для каждой. Для себя я еще нашел лекции Алексея Кладова. Он достаточно подробно и популярно объясняет, что и как работает в Rust.

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

Разработка сетевых приложений

Асинхронные интерфейсы, async/await

В стандартной библиотеке Rust из коробки идут интерфейсы, которые позволяют реализовывать свои асинхронные рантаймы и взаимодействовать с чужими из вашего кода. Но при этом сам Rust в себе рантайм не содержит — его реализуют сторонние разработчики. Для этого есть две самые популярные библиотеки: Tokio и async-std. Они похожи интерфейсами и производительностью, но Tokio появилась чуть раньше, поэтому более популярна и для неё больше пакетов.

Из самого основного, что нужно, Tokio включает в себя многопоточный work-stealing-рантайм для исполнения асинхронного кода. Это значит, что внутри рантайма есть тредпул, который на каждый поток CPU запускает поток ОС. Те асинхронные задачи, которые вы исполняете в своем коде, будут равномерно балансироваться между этими потоками — то есть вы сможете задействовать в коде все ядра процессора. 

Work-stealing означает, что один из потоков, выполнив все свои задачи, может начать выполнять задачи из очереди другого потока. Это позволяет нагружать процессор более равномерно, и у вас не будет ситуации, когда один поток, выполнив свои задачи, простаивает в ожидании новых.

Кроме этого, Tokio включает в себя часть стандартной библиотеки, отвечающей за работу с асинхронным кодом и несет огромную экосистему уже готовых пакетов. В целом в Tokio это очень похоже на подход, который используется в языке Go. Только горутины (goroutine) в Rust называются тасками (task), которые уже рантайм балансируют между потоками. Вот достаточно простой пример TCP сервера:

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

Или, например, так же как в Go, в Rust вместе с Tokio можно создать каналы, которые позволят обмениваться данными между тасками через передачу сообщений:

Выводы

Rust может выступить более удобной заменой для языков C и C++ в качестве языка системной разработки. Например, на нем можно писать модули для Linux, которые до сих пор нельзя было писать на C++. 

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

И наконец, Rust хорошо подходит для написания модулей для других языков (Lua, JS, Python), если мы хотим ускорить код или в том языке нет библиотек, которые мы хотели бы использовать.

Конференция Highload++ Foundation пройдет 17 и 18 марта в Москве, в Крокус-Экспо. Описание докладов и раcписание уже готовы. Билеты можно купить на сайте.

А сейчас идет открытое голосование по Open Source трибуне, где определятся 5 лучших решений. Отдайте свой голос за то, что вам нравится и помогите определить лучших!

Комментарии (26)


  1. cepera_ang
    14.02.2022 10:38
    +3

    Приятно читать очередную статью о том, что RIIR — это решение всех проблем :)


  1. k-morozov
    14.02.2022 11:45
    +3

    Интересно, много в команде rust-разработчиков? Искали со стороны или свои переобучались?


    1. oleggator Автор
      14.02.2022 16:28
      +1

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


  1. mayorovp
    14.02.2022 11:59

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



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


    1. oleggator Автор
      14.02.2022 16:43
      +3

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


      1. mayorovp
        14.02.2022 18:13
        +2

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


  1. Ddnn
    14.02.2022 12:56

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

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


    1. Gorthauer87
      14.02.2022 14:20
      +3

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


    1. jonywtf
      16.02.2022 15:23
      +2

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


    1. oleggator Автор
      16.02.2022 21:56
      +1

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

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


  1. DirectoriX
    14.02.2022 14:32
    +1

    Интересная статья, спасибо.

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


    1. oleggator Автор
      14.02.2022 16:38
      +1

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


  1. Vest
    14.02.2022 16:17
    +2

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

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

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


  1. AlonsoDelToro
    14.02.2022 20:38
    +1

    Спасибо за статью. Оставлю свое скромное мнение про 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 кроме существенно менее удобного синстаксиса работы с ней, может кто ни будь пояснит?

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


    1. Gordon01
      14.02.2022 23:55
      +4

      Касаемо 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 — он совершает ошибку.


      1. AlonsoDelToro
        15.02.2022 02:36
        +1

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

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

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

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

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


        1. Vest
          15.02.2022 13:40

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


        1. Gordon01
          15.02.2022 14:02
          +2

          Касаемо 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 и т.п. то код очень сильно увеличивается в размере, пишется намного дольше, становиться сложнее для восприяти и рефакторинга

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

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

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

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


        1. DarkEld3r
          15.02.2022 14:41
          +3

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

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


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

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


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


    1. PROgrammer_JARvis
      15.02.2022 10:26
      +4

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

      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 из той же Джавы.

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


      1. AlonsoDelToro
        15.02.2022 22:56
        -1

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

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

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

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

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


        1. DarkEld3r
          16.02.2022 00:50
          +2

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

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


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

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


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

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


    1. DarkEld3r
      15.02.2022 14:55
      +1

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

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


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


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


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

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


      1. Gordon01
        15.02.2022 15:10
        +2

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

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

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


      1. AlonsoDelToro
        15.02.2022 22:16

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

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

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

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

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


        1. DarkEld3r
          16.02.2022 01:12

          Если вы строку оборачиваете в 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 это типично для прикладного кода, в данном случае я рассматриваю реальную практику применения которую наблюдаю.

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