Ржавая очевидность
Каждый ищет в изыках программирования что-то своё: кому-то важна функциональная сторона, кому-то богатство библиотек, а другой сразу обратит внимание на длину ключевых слов. В данной статье я хотел бы рассказать, что особенно важно для меня в 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
Конечно, я слышу громкие возгласы, что нулевые указатели просто несут смысл несуществующего объекта, который мы в 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
Rust — не тёмная магия: он не оживляет мертвецов и не превращает воду в вино. Точно также, он не решает все проблемы нашего ремесла. Однако, он заставляет нас думать и писать код таким образом, что потенциальные проблемы оказываются на поверхности. В каком-то смысле, Rust искривляет реальность программирования, позволяя нам легче передвигаться в ней, как warp-drive.