[Перевод] Чего из Rust мне не хватает в C
Об авторе. Федерико Мена-Кинтеро — мексиканский программист, один из основателей проекта GNOME, автор книги «Язык программирования Rust».
Librsvg достиг переломного момента: внезапно выясняется, что легче портировать некоторые основные части из C на Rust, чем просто добавить аксессоры. Кроме того, всё больше «мяса» библиотеки сейчас написано на Rust.
Сейчас мне приходится часто переключаться между двумя языками, и C теперь выглядит очень, очень примитивным.
Элегия C
Я влюбился в C около 24 лет назад. Выучил азы по второму изданию «The C Programming Language by K&R» в переводе на испанский. До этого я использовал достаточно низкоуровневый Turbo Pascal, с указателями и ручным распределением памяти, так что C казался приятным и придающим сил.
K&R — отличная книга для выработки стиля и лаконичности. Эта маленькая книжка даже научит вас реализовать простой malloc()/free()
, что поистине просветляет. Даже низкоуровневые конструкции можно вставлять в самом языке!
В последующие годы я хорошо освоил C. Это небольшой язык с маленькой стандартной библиотекой. Вероятно, идеальный язык для реализации ядра Unix на 20 000 строк кода или около того.
GIMP и GTK+ научили меня причудливой объектной ориентации в С. GNOME научил, как поддерживать крупные программные проекты на С. Начало казаться, что первый проект в 20 000 строк кода C можно более или менее полно понять за несколько недель.
Но кодовые базы уже не такие маленькие. Наш софт теперь выдвигает огромные требования к функциям стандартной библиотеки языка.
Мои приятные моменты работы с C
Первый раз прочитал исходники POV-Ray и научился объектной ориентации и наследованию в С.
Прочитал исходники GTK+ и изучил стиль программирования с удобочитаемым, поддерживаемым и чистым кодом.
Прочитал исходники SIOD, затем первые исходники Guile — и понял, как интерпретатор Scheme можно написать на C.
Написал первые версии Eye of GNOME и настроил рендеринг микротайлов.
Неприятные моменты
Когда в команде Evolution всё шло наперекосяк. Нам тогда пришлось купить машину Solaris просто чтобы иметь возможность купить Purify; тогда не было Valgrind.
Отладка взаимных блокировок gnome-vfs.
Безрезультатная отладка Mesa.
Принял первоначальную версию Nautilus-share и обнаружил, что там ни разу не используется free()
.
Пытался рефакторить код, где я понятия не имел о стратегии управления памятью.
Попытался сделать библиотеку из кода, набитого глобальными переменными без единой статической функции.
Но ладно, давайте всё-таки поговорим о тех вещах Rust, которых мне не хватает в C.
Автоматическое управление ресурсами
Одной из первых статей, которые я прочитал о Rust, была статья «В Rust никогда не придётся закрывать сокет». Rust позаимствовал идеи C++: это идиома «получение ресурса есть инициализация» (RAII), умные указатели, он добавил принцип единственной ответственности для величин и автоматическое, детерминированное управление ресурсами в очень аккуратном виде.
- Автоматическое: не нужно вручную расставлять
free()
. Память освобождается, файлы закрываются, мьютексы разблокируются вне зоны видимости. Если нужно обернуть внешний ресурс, просто реализуете трейт Drop, и это в основном всё. Такой ресурс становится как будто частью языка, потому что потом не нужно вручную нянчиться с ним. - Детерминировано: ресурсы создаются (память выделяется, происходит инициализация, файлы открываются и т.д.) и уничтожаются вне зоны видимости. Здесь нет сборки мусора: всё действительно уничтожается с закрытием скобки. Вы начинаете рассматривать потоки данных в программе как дерево вызовов функций.
Когда постоянно забываешь освободить/закрыть/уничтожить объекты C или, ещё хуже, пытаешься найти в чужом коде места, где забыли это сделать (или сделали дважды, что неправильно)… Нет, я не хочу снова этим заниматься.
Дженерики (обобщённые типы)Vec
действительно представляет собой вектор, элементы которого имеют размер T
. Это не массив указателей к отдельно выделенным объектам. Он компилируется специально для кода, который может обрабатывать только объекты типа T
.
Написав кучу мусорных макросов на C для подобных вещей… не хочу снова этим заниматься.
Типажи (трейты) — не просто интерфейсы
Rust — это не Java-подобный объектно-ориентированный язык. Вместо этого у него есть типажи, которые на первый взгляд кажутся интерфейсами Java — простой способ динамической диспетчеризации: типа если объект реализует Drawable
, то вы можете предположить наличие метода draw ()
.
Но типажи способны на гораздо большее.
Связанные типы
У типажей могут быть ассоциированные (связанные) типы. Например, типаж Iterator
может выполняться таким образом:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option;
}
Это значит, что каждый раз при выполнении Iterator
для какого-то итерируемого объекта нужно также определить тип Item
для перебираемых элементов. Если выполняется next()
и элементы ещё не закончились, то вы получите Some(YourElementType)
. Если у итератора закончились элементы, то он вернёт None
.
Связанные типы могут ссылаться на другие типажи.
Например, в Rust вы можете использовать циклы for
везде, где выполняется типаж IntoIterator
:
pub trait IntoIterator {
/// The type of the elements being iterated over.
type Item;
/// Which kind of iterator are we turning this into?
type IntoIter: Iterator- ;
fn into_iter(self) -> Self::IntoIter;
}
При выполнении такого типажа следует указать и тип Item
в итераторе, и тип IntoIter
— фактический тип для Iterator
, который определяет состояние итератора.
Таким образом можно создавать паутину типов, которые ссылаются друг на друга. У вас может быть типаж «Я могу выполнить foo и bar, но только если предоставите тип, способный делать то и то».
Срезы
Я уже писал об отсутствии срезов строк в C и о том, как это неудобно, когда к ним привыкнешь.
Современные инструменты для управления зависимостями
Вместо этого:
- Вызывать
pkg-config
вручную или с помощью макросов Autotools. - Разбираться с путями в include для заголовочных файлов…
- … и файлов библиотек.
- И надеяться, что у пользователя установлены правильные версии библиотек.
Вы просто пишете файл Cargo.toml
, в котором перечисляете названия и версии своих зависимостей. Они загружаются c известного адреса или из другого указанного места.
Больше не нужно бороться с зависимостями. Всё сразу работает по команде cargo build
.
Тесты
Использовать юнит-тесты в C очень трудно по нескольким причинам:
- Внутренние функции зачастую статические. Это значит, что их нельзя вызвать за пределами исходного файла, где их объявили. Программа теста должна или сделать
#include
исходного файла с этими статическими функции, или использовать директивы#ifdef
для удаления статики только на время тестирования. - Нужно пошаманить с Makefile, чтобы связать тестовую программу только с частью кода, где есть зависимости, или только с остальным кодом.
- Следует выбрать фреймворк для тестирования. Зарегистрировать там тесты. Изучить фреймворк.
В Rust вы пишете в любом месте программы или библиотеки:
#[test]
fn test_that_foo_works() {
assert!(foo() == expected_result);
}
… и когда набираете cargo test
, ОН БЛИН ПРОСТО РАБОТАЕТ. Этот код связывается только с тестовым бинарником. Не нужно ничего компилировать дважды вручную или химичить с Makefile, или думать, как извлечь внутренние функции для тестирования.
Для меня это вообще киллер-фича.
Документация с тестами
Rust генерирует документацию из комментариев в синтаксисе Markdown. Код из документации запускается как тесты. Вы можете описать назначение функции и одновременно её протестировать:
/// Multiples the specified number by two
///
/// ```
/// assert_eq!(multiply_by_two(5), 10);
/// ```
fn multiply_by_two(x: i32) -> i32 {
x * 2
}
Код вашего примера запускается в виде теста и гарантирует, что документация соответствует фактическому коду.
Дополнение 23.02.2018: QuietMisdreavus описал, как rustdoc преобразует doctests в исполняемый код. Это магия высокого уровня и чрезвычайно интересно.
Гигиенические макросы
У Rust есть гигиенические макросы, лишённые всех проблем с неумышленным скрытием идентификаторов в коде C. Вам не нужно писать каждый символ в круглых скобках, чтобы макрос max (5 + 3, 4)
правильно работал.
Нет автоматических приобразований
Все эти баги в C, из-за которых int
случайно преобразуется в short
, char
или что-то ещё — Rust так не поступает. Здесь необходимо явное преобразование.
Нет целочисленного переполнения
Сказано достаточно.
В целом, в безопасном Rust отсутствует неопределённое поведение
В Rust считается багом в языке, если нечто в «безопасном Rust» (то есть всё, что разрешено писать вне блоков unsafe {}
) приводит к неопределённому поведению. Можете поставить >>
перед отрицательным целым числом — и всё будет предсказуемо работать.
Сопоставление шаблонов
Знаете эти предупреждения gcc
, когда оператор switch()
применяется для enum, не обрабатывая все значения? Ну словно маленький ребёнок.
В различных местах Rust используется сопоставление шаблонов. Он способен на такой фокус для перечислений внутри match()
. Он может выполнить деконструкцию, так что функция возвращает несколько значений:
impl f64 {
pub fn sin_cos(self) -> (f64, f64);
}
let angle: f64 = 42.0;
let (sin_angle, cos_angle) = angle.sin_cos();
Можно запустить match()
на строках. ВЫ МОЖЕТЕ СРАВНИТЬ ГРЁБАНЫЕ СТРОКИ.
let color = "green";
match color {
"red" => println!("it's red"),
"green" => println!("it's green"),
_ => println!("it's something else"),
}
Показать непонятную строчку?
my_func(true, false, false)
А что если вместо этого сопоставить шаблоны, соответствующие аргументам функции:
pub struct Fubarize(pub bool);
pub struct Frobnify(pub bool);
pub struct Bazificate(pub bool);
fn my_func(Fubarize(fub): Fubarize,
Frobnify(frob): Frobnify,
Bazificate(baz): Bazificate) {
if fub {
...;
}
if frob && baz {
...;
}
}
...
my_func(Fubarize(true), Frobnify(false), Bazificate(true));
Стандартная, полезная обработка ошибок
Я много говорил об этом. Больше никакого boolean без объяснения ошибки, никаких случайных пропусков ошибок, никакой обработки исключений с нелокальными прыжками.
#[derive (Debug)]
Если пишете новый тип (скажем, структуру с кучей полей), можете указать #[derive(Debug)]
— и Rust будет знать, как автоматически вывести содержимое этого типа для отладки. Больше не придётся писать специальную функцию, которую нужно вызывать вручную в gdb чтобы просто проверить кастомный тип.
Закрытия
Больше не придётся вручную передавать указатели функций и user_data
.
Заключение
Я ещё не закончил работу над главой о «бесстрашном параллелизме», где компилятор может предотвратить гонку данных в потоковом коде. Предполагаю, что она станет поворотным моментом для тех, кто ежедневно пишет параллельный код.
C — это старый язык с примитивными конструкциями и примитивными инструментами. Это был хороший язык для маленького однопроцессорного ядра Unix, которое запускалось в доверенной академической среде. Для современного ПО он уже не подходит.
Rust трудно выучить, но это того стоит. Трудно, потому что требуется хорошее понимание кода, который хотите написать. Думаю, это один из тех языков, которые развивают вас как программиста и позволяют решать более амбициозные задачи.