Обновить

Ржавая очевидность

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

Особенно отмечу, что не пытаюсь полностью описать язык, а только одну его сторону. Это мой личный взгляд на философию Rust, не обязательно совпадающий с официальной позицией разработчиков! Кроме того, Rust не будет очевиден пришельцу из других языков: кривая обучение довольно резкая, и не раз компилятор заставит вас мысленно сказать «wtf» на пути к просветлению.

Опасный код — unsafe


«Обычный» код считается безопасным по доступу к памяти. Это пока не доказано формально, но такова задумка разработчиков. Безопасность эта не значит, что код не упадёт. Она значит, что не будет чтения чужой памяти, как и многих других вариантов неопределённого поведения. Насколько мне известно, в обычном коде всё поведение определено. Если вы попытаетесь сделать что-то незаконное, что не может быть отслежено во время сборки, то худшее, что может случиться, это контролируемое падение.

Если же вы делаете что-то за пределами простых правил языка — вы обрамляете соответствующий хитрый код в unsafe {}. Так, например, можно найти небезопасный код в реализации примитивов синхронизации и умных счётчиков (Arc, Rc, Mutex, RwLock). Заметьте, что это не делает данные элементы опасными, ибо они выставляют наружу совершенно безопасный (с точки зрения Rust) интерфейс:

// в этом примере наш объект владеет GL контекстом и гарантирует,
// что вызовы к нему идут только из родительского потока
fn clear(&self) {
    unsafe { self.gl.clear(gl::COLOR_BUFFER_BIT) }
}

Итак, если вам на глаза попалась функция с блоком unsafe, нужно внимательно присмотреться к содержимому. Если нет — будьте спокойны, поведение функции строго определено (нет undefined behavior). Пользуясь случаем… привет, С++!

Исключения, которых нет


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

Вот и автор ZeroMQ решил, что эта сложность только мешает, и разработчики Rust с ним согласны. У нас нет исключений, а потенциальные ошибки являются частью возвращаемых (алгебраических) типов:

fn foo() -> Result<Something, SomeError>;
...
match foo() {
   Ok(t) => (...), //успех!
   Err(e) => (...), //ошибка!
}

Вы видите, как и что возвращают функции, и вы не можете взять результат, не проверив на потенциальные ошибки (привет, Go!).

Ограниченный вывод типов


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

# комментарии, конечно, помогают, но типы были бы надёжнее
def save_mesh(out, ob, log): # -> (Mesh, bounds, faces_per_mat):
    ... # 50 строк на питоне без единого типа внутри

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

Локальные переменные


Это свойство кажется таким простым и очевидным… пока не появляются ребята из Oberon (привет!). У глобальных переменных есть положительные моменты, но они затрудняют понимание фрагментов кода.

// entity - локальная переменная, указатель на текущий элемент коллекции
for entity in scene.entities {
    // её область жизни - данный цикл и не строчки больше
   entity.draw()
}


Неизменяемые переменные


Ах да, переменные по умолчанию нельзя изменить дважды. Хотите менять после инициализации — будьте добры указать слово mut. При этом компилятор гарантирует это свойство:

fn foo(x: &u32) -> u32 {
   ... // мы знаем, что переменная не поменялась, и компилятор знает
   *x + 1 
}

Или с изменяемым состоянием:

fn foo(x: &mut u32) {
  ... // мы сознательно меняем значение, но мы знаем, как и компилятор,
  // что никто другой не меняет и даже не читает его, пока мы здесь
 *x = *x + 1;
}

Сравните это с const в C:

unsigned foo(const unsigned *const x) {
   ... // мы может и знаем, что переменная не поменялась в теле этой функции
  // но ничто не мешает менять её в другом потоке, так что компилятор ничего не знает
  return *x + 1;
}


Указатели, которых вы не увидите


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

Конечно, я слышу громкие возгласы, что нулевые указатели просто несут смысл несуществующего объекта, который мы в Rust всё равно так или иначе выражаем со всеми вытекающими логическими ошибками. Да, есть Option<&Something>, однако это не совсем то же самое. С точки зрения Rust, ваш код, скажем на Java, изобилует указателями, которые могут в один прекрасный момент упасть при доступе. Вы может и знаете, какие из них не могут быть нулевыми, но держите это в голове. Ваш коллега не может читать ваши мысли, да и компилятор не способен уберечь вас самих от провала памяти.

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

fn get_count(input: Option<&str>) -> usize {
    match input {
        Some(s) => s.len(),
        None => 0,
    }
}

Конечно, вы всё также можете упасть на месте, где ожидаете чего-то, чего нет. Но падение это будет осознанным (через вызов unwrap() или expect()) и явным.

Модули


Всё, что в области видимости, можно найти по местным объявлениям и ключевому слову use. Расширять область видимости можно прямо в блоках кода, что ещё более усиливает локальность:

fn myswap(x: &mut i8, y: &mut i8) {
    use std::mem::swap;
    swap(x, y);
}

Проблема по существу есть только в C и С++, но там она весьма доставляет. Как понять, что именно в области видимости? Нужно проверить текущий файл, потом все включаемые файлы, потом все их включаемые, и так далее.

Композиция вместо наследования


В Rust нет наследования классов. Вы можете наследовать интерфейсы (traits), но структуры всё равно должны явно реализовывать все интерфейсы, которые унаследовал нужный вам интерфейс. Допустим, вы видите вызов метода object.foo(). Какой именно код будет исполнен? В языках с наследованием (особенно — множественным), вам нужно поискать данный метод в классе типа object, потом в его родительских классах, и так далее — пока не найдёте реализацию.

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

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

Явная реализация обобщений


Отдельно хочется отметить момент, что для удовлетворения определённого интерфейса, его нужно явно указать:

impl SomeTrait for MyStruct {...}

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

Обобщённые ограничения


Шаблоны в С++ — это, как ни странно, элементы мета-программирования. Этакие повзрослевшие макросы, полные по Тьюрингу. Они позволяют сэкономить кучу кода и творить настоящие чудеса (привет, Boost!). Однако, сказать, что конкретно случится в момент подстановки конкретного типа — трудно. Какие требования к подставляемому типу — тоже не сразу понятно.

В Rust (и во многих других языках) вместо шаблонов есть обобщения. Их параметры обязаны предоставлять определённый интерфейс для подстановки, и корректность таких обобщений проверяется достоверно компилятором:

// входные параметры должны быть сравнимы друг с другом
pub fn max<T: Ord>(v1: T, v2: T) -> T

Стоит отметить, что комитет признаёт важность концепции, так что скоро мы можем увидеть нечто подобное и в С++.

Неспециальные обобщения


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

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

Rust — не тёмная магия: он не оживляет мертвецов и не превращает воду в вино. Точно также, он не решает все проблемы нашего ремесла. Однако, он заставляет нас думать и писать код таким образом, что потенциальные проблемы оказываются на поверхности. В каком-то смысле, Rust искривляет реальность программирования, позволяя нам легче передвигаться в ней, как warp-drive.
Теги:rustязыки программированияc++javago
Хабы: Программирование Совершенный код Rust
Рейтинг +37
Количество просмотров 17,7k Добавить в закладки 68
Комментарии
Комментарии 86

Похожие публикации

Факультет Java-разработки
10 марта 2021180 000 ₽GeekBrains
Java Developer. Professional
11 марта 202160 000 ₽OTUS
C++ Developer. Professional
12 марта 202160 000 ₽OTUS
Java QA Engineer
16 марта 202160 000 ₽OTUS
Основы Google Ads: подготовка к сдаче экзамена
18 марта 2021БесплатноНетология

Лучшие публикации за сутки