Фичи Rust, которых не хватает в C

чего не хватает в C

Перевод статьи Федерико Мена-Кинтеро, который, наряду с Мигелем де Икаса, основал проект GNOME — широко используемую, свободную графическую среду, в основном для систем GNU/Linux. Перед этим он некоторое время поддерживал GIMP. Сейчас Федерико активно развивает библиотеку librsvg с использованием языка программирования Rust. По его мнению, разработка достигла момента, когда портирование некоторых крупных компонент с C на Rust выглядит более лёгкой задачей, чем просто добавление аксессоров к ним. Федерико часто приходится переключаться с C на Rust и обратно, и в статье он рассказывает, почему считает C очень и очень примитивным языком для современного ПО.

Своего рода элегия по C

Я влюбился в язык программирования C около 24-ёх лет назад. Я выучил основы, прочитав испанский перевод второго издания «Языка программирования C» Кернигана/Ритчи (K&R). До этого я писал на Turbo Pascal в довольно низкоуровневой манере — с указателями и ручным выделением памяти. После него C казался освежающим и мощным.

К&R — это отличная книга благодаря стилю изложения и лаконичности программирования. Эта книга даже учит, как реализовать простые функции malloc/free, что крайне поучительно. Даже такие низкоуровневые конструкции, которые выглядят как часть языка, могут быть реализованы на самом языке!

В последующие годы я хорошо освоил C. Это простой язык с небольшой стандартной библиотекой. Наверное, это был идеальный язык для реализации ядер Unix в 20 000 строк кода или около того.

GIMP и GTK+ научили меня тому, как использовать модный объектно-ориентированный подход в C. GNOME показал, как поддерживать крупномасштабные проекты, написанные на C. Стало казаться, что 20 000 строк C кода — это проект, который можно практически полностью понять за пару недель.

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

Опыт использования C

Положительный

  • Чтение исходного кода проекта POV-Ray впервые и изучение того, как использовать объектно-ориентированный подход и наследование в чистом C;
  • Чтение исходного кода проекта GTK+ и изучение читаемого, поддерживаемого и чистого стиля написания кода на C;
  • Чтение исходного кода проекта SIOD, а также ранних исходников проекта Guile и понимание того, как интерпретатор Scheme может быть написан на C;
  • Написание первых версий Eye of Gnome и доработка системы микротайлового рендеринга.

Негативный

  • Работа в команде Evolution, когда программа постоянно падала. Мы были вынуждены приобрести машину с Solaris на борту, чтобы иметь возможность купить Purify; в те времена Valgrind-а еще не существовало;
  • Отладка взаимных блокировок потоков в gnome-vfs;
  • Безуспешная отладка Mesa;
  • Когда мне передали исходники первых версий Nautilus-share, я увидел, что free() вообще не используется;
  • Попытки рефакторинга кода, о стратегии управления памятью которого я не имел понятия;
  • Попытка сделать библиотеку из кода, кишащего глобальными переменными, и в котором ни одна функция не помечена как static.

Фичи Rust, которых не хватает в C

Автоматическое управление ресурсами

Один из первых блог-постов, которые я прочитал о Rust, назывался «В Rust вам никогда не придётся закрывать сокет». Rust заимствует у C++ идеи об идиоме RAII (Resource Acquisition Is Initialization, получение ресурса есть инициализация) и умных указателях, добавляет принцип единоличного владения для значений и предоставляет механизм автоматического, детерминированного управления ресурсами в очень изящной упаковке.

  • Автоматическое: не нужно вызывать free() вручную. Память освободится, файлы закроются, мьютексы разблокируются, когда переменные выйдут из зоны видимости. Если вам нужно написать обёртку для стороннего ресурса, то всё, что нужно сделать, это реализовать типаж Drop. Обёрнутый ресурс ощущается как часть языка, потому что вам не приходится нянчиться с его временем жизни вручную;
  • Детерминированное: ресурсы создаются (память выделяется и инициализируется, файлы открываются и т. д.) и уничтожаются, когда выходят из зоны видимости. Никакой сборки мусора: ресурсы действительно освобождаются, когда вы закрываете скобку. Вы начинаете видеть время жизни данных в своей программе как дерево вызовов функций.

После того, как постоянно забываешь освобождать/закрывать/уничтожать объекты в C, или, ещё хуже, пытаешься понять, где в чужом коде забыли сделать что-то из этого (или ошибочно сделали дважды)… я просто больше этого не хочу.

Дженерики

Vec<T> — это действительно вектор, размер элементов которого равен размеру объекта типа T. Это не массив указателей на объекты, память для которых выделялась отдельно. Он специально компилируется в код, который может работать только с объектами типа T.

После написания большого количества сомнительных макросов на C, чтобы сделать что-то похожее… я больше этого не хочу.

Типажи — это больше, чем просто интерфейсы

Rust — это не Java-подобный объектно-ориентированный язык, подробнее об этом можно прочитать в open-source книге «The Rust Programming Language». Вместо этого в нём есть типажи, которые поначалу похожи на интерфейсы в Java, — простой способ осуществления динамического переключения (dynamic dispatch), так что если объект реализует Drawable, то можно предположить, что у него есть метод draw().

Однако типажи — это более мощный инструмент. Одной из отличительных особенностей типажей можно считать ассоциированные типы (associated types). Например, Rust предоставляет типаж Iterator, который вы можете реализовать:

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

Это означает, что всякий раз, когда вы реализуете этот типаж для какого-либо объекта, поддерживающего итерирование, вы также указываете тип Item для значений, которые он выдаёт. Если вы вызываете next() и элементы ещё остались, вы получите Some(ТипВашегоЭлемента). Когда у вашего итератора закончатся элементы, он вернет None.

Ассоциированные типы могут ссылаться на другие типажи.

Например, в Rust вы можете использовать циклы for со всем, что реализует типаж IntoIterator:

pub trait IntoIterator {
    /// Тип элементов, по которым идёт итерация
    type Item;

    /// В какой тип итератора мы преобразуемся?
    type IntoIter: Iterator<Item=Self::Item>;

    fn into_iter(self) -> Self::IntoIter;
}

Когда реализуете этот типаж, вы должны указать и тип элементов, которые будет выдавать ваш итератор, и сам тип IntoIter, который реализует типаж Iterator и хранит состояние вашего итератора.

Таким образом, вы можете построить настоящую сеть типов, которые ссылаются друг на друга. Вы можете написать типаж, который говорит: «Я могу сделать foo и bar, но только если вы дадите мне тип, который умеет делать вот это и это».

Срезы

Я уже писал о том, насколько в C не хватает срезов (slices) для работы со строками и какая это головная боль, когда привык, что они под рукой.

Современные инструменты для управления зависимостями

Вместо того, чтобы

  • Запускать pkg-config руками или через Autotools-макрос;
  • Сражаться с include-путями в заголовочных файлах…
  • … и библиотечных файлах;
  • И, по сути, полагаться на то, что пользователь гарантирует установку верных версий библиотек,

вы пишете файл Cargo.toml, в котором перечисляются названия и версии всех наших зависимостей. Они будут загружены из общеизвестного источника или из любого другого, указанного вами.

Не нужно сражаться с зависимостями. Оно просто работает, когда вы набираете cargo build.

Тесты

В C очень сложно покрывать код тестами по нескольким причинам:

  • Внутренние функции часто помечены как static. Это означает, что они не могут быть вызваны вне файла, в котором эта функция определена. Тестовая программа вынуждена либо #include-ить содержимое исходника, в котором функция объявлена, либо использовать #ifdef, чтобы убирать static только при тестировании;
  • Вам придётся плясать с бубном вокруг вашего 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
}

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

Гигиеничные макросы

В Rust особые гигиеничные макросы, позволяющие избежать проблем, при которых во время разворачивания C макросов происходит непреднамеренное затенение идентификаторов в коде. Вам больше не нужно писать макросы, заключая все символы в скобки, чтобы max(5 + 3, 4) работал правильно.

Никакого неявного приведения типов

Все эти баги, которые появляются в C из-за непреднамеренного приведения int к short или к char и т. п. — в Rust их нет. Вы должны приводить типы явно.

Никакого целочисленного переполнения

Этим всё сказано.

Как правило, никакого неопределённого поведения в безопасном режиме

В Rust, если что-то вызывает неопределенное поведение в «безопасном режиме» (всё, что написано вне блоков unsafe {}), это расценивается как баг самого языка. Например, можно сделать побитовый сдвиг отрицательного целого числа вправо и произойдёт именно то, что вы ожидаете.

Сопоставление с образцом

Знаете, как gcc выдает предупреждение, если вы используете switch() с перечислением (enum), но обработаете не все варианты? Это детский сад по сравнению с Rust.

В 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 = "зеленый";

match color {
    "красный" => println!("Это красный"),
    "зеленый" => println!("Это зеленый"),
    _         => println!("Что-то другое"),
}

Вы же знаете, насколько такое плохо читается?

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));

Стандартная полезная обработка ошибок

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

#[derive(Debug)]

Если вы пишете новый тип (скажем, структуру с кучей полей), то можно написать #[derive(Debug)], и Rust будет знать, как автоматически напечатать содержимое этого типа для отладки. Больше не нужно руками писать специальную функцию, которую затем придётся вызывать из gdb, только для того, чтобы посмотреть содержимое полей пользовательского типа.

Замыкания

Вам больше не придётся передавать указатели на функцию и user_data вручную.

Заключение

Я пока не попробовал «fearless concurrency», где компилятор может предотвращать гонки данных в многопоточном коде. Я полагаю, что это в корне меняет положение дел для людей, которые пишут параллельный код на регулярной основе.

C — это старый язык с примитивными конструкциями и примитивными инструментами. Он хорошо подходил для небольших однопроцессорных Unix-ядер, которые работали в доверенных, академических средах. Но для современного программного обеспечения он больше не подходит.

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

Перевод статьи «Rust Things I Miss In C»

Ещё интересное для вас:
Тест: какой язык программирования вам стоит выбрать для изучения?
Тест: как хорошо вы разбираетесь в Data Science?
Соревнования и бесплатная онлайн-школа для программистов