Я часто замечаю, как Rust сравнивают с C++ в плохом смысле, критикуя в целом дизайн языка, выставляя его столь же сложным и перегруженным как C++. Также я вижу, как язык Си приводят в качестве хорошего примера: минималистичный язык, прост для изучения и не требует годы для освоения.
С последним утверждением не могу не согласиться. Rust действительно намного сложнее на фоне Си. Я бы сам ни за что не посоветовал кому-либо учить Rust как первый язык программирования (как и C++). Я считаю эту черту очень показательной.
Тем не менее, я считаю Rust куда ближе к Си, чем к C++ с точки зрения дизайна языка. И не просто так считаю, я пишу на Rust именно с этой мыслью. В этой статье я опишу основные свои тезисы и сравню оба языка, находя много общего, противопоставляя это C++ и ему подобным "ООП языкам".
Если коротко сформулировать мой подход, я бы описал его как: Rust - это memory safe Си, с borrow checker и владением. Он относительно прост и не имеет перегруженных деталей в своём основании. Во многом Rust даже проще чем C# или Java.
Итак, что же делает Rust простым?

Нет классов
Начнем с типов данных. В Rust, как и в Си, нет классов. Вместо этого обычный способ описать тип это создать структуру:
struct Book {
title: String,
pages: usize,
}
impl Book {
fn new(title: String, pages: usize) -> Self {
Self { title, pages }
}
}
Можно обратить внимание на то что объявление структуры и её ассоциированная функция new описываются в разных блоках. Синтаксически это схоже сишному разделению данных и функций, описывающих работу с этими данными:
#include <stdlib.h>
#include <string.h>
typedef struct {
char *title;
size_t pages;
} Book;
Book new_book(const char *title, size_t pages) {
char *new_title = malloc(strlen(title) + 1);
strcpy(new_title, title);
return (Book) { .title = new_title, .pages = pages };
}
void delete_book(const Book *book) {
free(book->title);
}
В описании классов напротив, принято определять данные и поведение в одном месте. Это больше соответствует философии ООП, где объект содержит сразу и данные, и своё поведение. Но синтаксис это то, что лежит на поверхности. Интересные вещи глубже.
Конструкторы - неотъемлемая часть ООП мира, но в Rust их так же нет. Функция new в примере выше это самая обычная функция, у неё может быть любое имя (new - стандартное имя, но может быть другим). Единственное отличие от сишной new_book это неймспейс, в Rust чтобы вызвать функцию нужно также указать неймспейс (пространство имён) Book:
let book = Book::new(/**/);
Сторого говоря, ничего не мешает определить функцию-конструктор вне неймспейса как в Си, но таким образом мы следуем общему стилю кода: подобные "конструкторы" принято ассоциировать с типом.
Но в чём принципиальное отличие от конструкторов в ООП языках? Конструкторы это не просто функции, это специальные функции с рядом требований:
- Должны иметь то же имя, что и имя класса
- Из-за ограничений на имя конструктора часто позволяется перегрузка, чтобы обеспечить возможность создания объектов для разного набора параметров
- Должны создавать экземпляр класса и ничего другого: результат вызова конструктора это всегда объект класса
- Вызываются с использованием ключевого слова
new(есть исключения Python, Ruby и тд) - Имеют (обычно неявный) параметр
thisкоторый ссылается на не-до-инициализированный объект класса
В Rust и Си вместо этого любой объект конструируется с помощью литерала: Book { title, pages } или (Book) { .title = new_title, .pages = pages }. Конструктор, как отдельная конструкция языка, просто не нужен. То что в ООП языках является специальной фичей, в Rust и Си просто обычные функции. Вместо перегрузки конструктора можно просто написать другие функции с иным набором параметров. Собственно говоря, в Rust вообще нет перегрузок для функций, как и в Си.
Что действительно отличает Rust код от сишного, это его safe-ная природа. Пока в Си нам требуется также описывать "деструкторы" для объектов void delete_book(const Book *book) {/**/} - Rust делает это автоматически. Также в Rust нам практически никогда не нужно вручную освобождать ресурсы, в то время как в Си это необходимо.
Ещё интересное отличие это move-семантика в Rust. Пока в Си нам нужно вручную копировать данные strcpy(new_title, title) - в Rust мы просто передаём владение String. На самом деле ничего не мешает в Си симулировать подобную move семантику, вместо копирования можно неявно присвоить "владение" данными самой структуре Book - но этот подход неочевиден и никак не выражается в типах.
Методы
Что если требуется определить какое-то поведение для типа? ООП языки добавляют отдельную категорию функций - методы. В Си все функции это просто функции. Вместо создания метода можем просто определить функцию и с помощью имени, которое начинается с book_, подчеркнем отношение этой функции к типу Book:
size_t book_title_len(const Book *book) {
return strlen(book->title);
}
Но в Rust, используя неймспейсы и специальный параметр self, можем описать функцию которая будет очень похожа на метод. На самом деле в Rust для простоты эти функции так и называются - "методы", про отличия от методов классов в ООП языках я напишу далее:
impl Book {
fn title_len(&self) -> usize {
self.title.len()
}
}
От Си это отличается лишь синтаксически: вызываем функцию через точку book.title_len(), а не book_title_len(book). Что на самом деле только добавляет эргономики. Так я могу смотреть все методы объекта в редакторе кода напечатав ., затем увидеть список доступных методов. Или вообще писать длинные цепочки вызовов o.proc().make().build() вместо build(make(proc(o))). При необходимости классический синтаксис вызова функции также доступен: Book::title_len(&book). В конце концов, можно убрать ненужные префиксы по типу book_, так как методы уже сами по себе располагаются в неймспейсе типа.
Но, несмотря на различия в синтаксисе, методы в Rust это по-прежнему просто функции. Чем это отличается от методов класса? Рассмотрим на примере абстрактного ООП языка, что если я хочу вызвать метод у объекта:
void printTitleLen(Book book) {
print(book.getTitleLen());
}
В результате должен вызываться метод .getTitleLen() у типа Book? Да.. но не обязательно. В ООП языках есть наследование и методы можно переопределять (не всегда: в Java есть final методы, в С++/С# метод должен быть virtual чтобы его можно было переопределить, и тд). По умолчанию семантика кода выше не "вызови функцию Book::getTitleLen", а "сделай виртуальный вызов метода getTitleLen у объекта типа Book, или какого-то другого типа что наследует Book". Это принципиально различает Rust/Си и прочие языки где есть переопределение метода в какой-то форме.
Нет наследования
В Rust, как и в Си, нельзя наследовать типы. Это и не нужно. К примеру, чтобы "расширить тип" добавив туда новые данные используется композиция:
struct AuthorsBook {
book: Book,
author: String,
}
Чтобы получить все методы Book можно также реализовать Deref. Так, функции и методы что работали с &Book будут так же вызываться с параметром &AuthorsBook. Тут нет никакой магии и виртуальных вызовов, authors_book.title_len() просто будет эквивалентно authors_book.book.title_len() (при этом поле book может быть приватным). Подобная композиция столь же проста и минималистична как и в Си.
Однако в ООП-языках наследование часто используется как один из механизмов полиморфизма, об этом далее.
Динамический полиморфизм
Для примера, у нас есть удобная функция печати книги в консоль book_print:
void book_print(const Book *book) {
printf("%s [%zu pages]\n", book->title, book->pages);
}
int main(void) {
Book book = new_book("The C programming Language", 270);
book_print(&book);
delete_book(&book);
return 0;
}
Создаем книгу, вызываем функцию и видим текст The C programming Language [270 pages] в терминале. Отлично! Но что если мы хотим печатать разные объекты разных типов? Именно эта проблема решается тем, что называется полиморфизм.
Как вообще полиморфизм выражается в Си? Классический подход это таблица виртуальных функций:
typedef struct {
void (*print)(const void*);
} PrintVTable;
void print(const PrintVTable *pvt, const void *obj) {
pvt->print(obj);
}
И тут происходит магия: статические типы стираются, функция print удивительным образом может работать с объектами любого типа. Нужно лишь предоставить разумную реализацию PrintVTable для переданного объекта. У нас как раз есть функция book_print, указатель на эту функцию можно передать в таблицу создав static PrintVTable BOOK_PRINT = { .print = book_print }; - но, важный момент, из-за стирания типов полиморфный print принимает объект как const void *obj. Чтобы работать с этой сигнатурой нужно немного переписать book_print:
void book_print(const void *obj) {
const Book *book = obj;
printf("%s [%zu pages]\n", book->title, book->pages);
}
static PrintVTable BOOK_PRINT = { .print = book_print };
int main(void) {
Book book = new_book("The C programming Language", 270);
print(&BOOK_PRINT, &book); // The C programming Language [270 pages]
delete_book(&book);
return 0;
}
Это выдаст аналогичный результат как и в случае вызова book_print, по сути мы эту же функцию и вызываем, просто через дополнительный указатель.
Теперь тот же самый пример в Rust:
trait Print {
fn print(&self);
}
impl Print for Book {
fn print(&self) {
println!("{} [{} pages]", self.title, self.pages);
}
}
fn print(obj: &dyn Print) {
obj.print();
}
fn main() {
let book = Book::new("The C programming Language".to_owned(), 270);
print(&book); // The C programming Language [270 pages]
}
Описываем трейт Print, подобно тому как мы описывали PrintVTable. Это будет наш полиморфный интерфейс. Отдельная реализация трейта для типа impl Print for Book - аналогично созданию static PrintVTable BOOK_PRINT = { .print = book_print };. Полиморфная функция fn print(obj: &dyn Print) - как видно, она принимает параметр типа &dyn Print, но что это такое? В Rust dyn объект это указатель на объект + vtable реализации интерфейса. Это соответствует сишной сигнатуре void print(const PrintVTable *pvt, const void *obj) за исключением того что указатель и vtable передаются сразу вместе как один параметр, а не два как в Си. Что, на самом деле, отражает safe-ный подход в Rust: в Си нам приходится работать с нетипизированными указателями void *obj, мы обязательно должны убедиться что тип переданного объекта соответствует переданной таблице функций. В Rust создание таблицы происходит автоматически и обеспечивает полную типо-безопасность.
В результате функция print будет работать с объектами разных типов, требуя лишь реализации трейта Print:
struct Cat { name: String }
impl Print for Cat {
fn print(&self) {
println!("a cat named {}", self.name);
}
}
fn print(obj: &dyn Print) {
obj.print();
}
fn main() {
print(&Cat { name: "Nano".to_owned() }); // a cat named Nano
}
Принципиальный момент который сближает Rust и Си на фоне других языков: указатель на таблицу виртуальных функций дополняется к указателю на объект, вместо того, чтобы храниться внутри объекта как в C++/C#/Java и тд.
В Rust/Си в полиморфную функцию отдельно передаются два указателя:
book ─> [ title, pages ]
vtable ─> [ print ]
В ООП языках в функции передается один указатель на объект, а уже внутри него хранится указатель на таблицу виртуальных функций. В примере с нашим типом Book это выглядело бы так:
book ─> [ vtable, title, pages ]
│
└─> [ print ]
Дизайн языков Rust и Си старательно разделяет данные и поведение, в то время как ООП языки, придерживаясь собственной философии, наоборот их объединяют. Отсюда возникает интересное следствие: можно доопределять любое поведение для любых типов, даже тех что изначально не предполагались для полиморфного использования:
void int_print(const void *obj) {
const int *i = obj;
printf("%d\n", *i);
}
static PrintVTable INT_PRINT = { .print = int_print };
int main() {
int x = 67;
print(&INT_PRINT, &x); // 67
return 0;
}
Это же применимо к Rust:
impl Print for i32 {
fn print(&self) {
println!("{self}");
}
}
fn main() {
let x = 67;
print(&x); // 67
}
С другой стороны в ООП языках нельзя просто доопределить поведение какого-то типа (хотя есть extension methods в C# и monkey patching в некоторых языках), вам придётся либо реализовывать функционал через наследование (для этого нужно менять код класса), либо создавать новый тип.
Статический полиморфизм
До этого мы рассмотрели то что называется динамический полиморфизм. Функция fn print(obj: &dyn Print) динамически работает с любыми ссылками на объект, реализующий трейт Print. Но в Rust также есть дженерики (обобщенные параметры, generics) - они позволяют выражать статический полиморфизм:
trait Print {
fn print(&self);
}
impl Print for Book {
fn print(&self) {
println!("{} [{} pages]", self.title, self.pages);
}
}
fn print<P>(obj: &P)
where
P: Print,
{
obj.print();
}
fn main() {
let book = Book::new("The C programming Language".to_owned(), 270);
print(&book); // The C programming Language [270 pages]
}
Результат аналогичен примеру выше, но в чём разница?
Теперь функция print принимает дженерик параметр <P>. При вызове нужно статически подставить вместо P конкретный тип, в нашем случае это Book. В момент компиляции будет сгенерирована особая функция print, принимающая непосредственно Book в качестве аргумента:
fn print_book(obj: &Book) {
obj.print();
}
fn main() {
let book = Book::new("The C programming Language".to_owned(), 270);
print_book(&book); // The C programming Language [270 pages]
}
В этом случае компилятор не будет собирать под капотом таблицу виртуальных функций и дёргать её при вызове .print(), как в случае с dyn Print. Вместо этого напрямую вызывается Book::print без дополнительной косвенности. Из плюсов: это оптимизируется гораздо лучше так как компилятор знает конкретный тип для которого нужно генерировать код. Также это подразумевает что при компиляции крейта нельзя сразу сгенерировать код для дженеричной функции, вместо этого нужно сохранить код отдельно и компилировать только при вызове (возможно из другого крейта), зная подставленный тип. Отсюда произрастают и минусы: время компиляции дольше, а размер итогово бинарного файла раздувается, так как функцию нужно каждый раз генерировать заново для каждого нового типа.
В Си нет дженериков/шаблонов (_Generic упоминать не буду), однако похожий механизм шаблонизации кода можно эмулировать с помощью макросов. На мой вкус, это большое упущение в языке. Шаблонизировать код бывает нужно - только представьте типо-безопасный HashMap в Си, который статически знает тип ключа и значения, и всё это без макросов 😖
Но для чего вообще нужен dyn в Rust, кроме как для экономии времени компиляции и размера бинарного файла?
Бывает нужно работать с данными разных типов, которые имеют общую реализацию трейта. Наглядный пример - хранить ссылки на значения разных типов в одном массиве/векторе:
let book = Book::new("The C programming Language".to_owned(), 270);
let nano = Cat { name: "Nano".to_owned() };
for obj in [&book as &dyn Print, &nano] {
obj.print();
// The C programming Language [270 pages]
// a cat named Nano
}
Это похоже на случаи когда в Си нам нужен тип void*, только в Rust это реализуется полностью safe-но и типо-безопасно. Тем не менее, если отбросить требования к memory safety, оба подхода схожи в Rust и в Си.
Нет перегрузки функций и параметров по умолчанию
В Rust, как и в Си, нельзя перегружать функции:
fn proc(x: i32) {}
fn proc(x: i32, y: i32) {} // error: the name `proc` is defined multiple times
Также нет поддержки параметров по умолчанию:
fn proc(x: i32, y: i32 = 0) {} // error: parameter defaults are not supported
Всё это избавляет от большого количества головной боли и делает языки проще и минималистичнее 😖
Нет исключений
В Rust нет исключений. Для сигнализации об ошибках принято возвращать какое-то особое значение, которое обычно представляется типом Result:
fn proc(x: i32, y: i32) -> Result<i32, Error> {
if y == 0 {
Err("division by zero".into())
} else {
Ok(x / y)
}
}
Это схоже с подходом в Си. Мы не можем выбросить исключение, вместо этого возвращаем какое-то особо значение (например -1), null или код ошибки.
Но ведь в Rust есть паника? Разве это не те же "исключения"?
Хотя механизм работы исключений и паники схож: они разматывают стек, семантически это принципиально разные вещи. Исключения создаются чтобы их ловить. Паника - нет. Панику можно поймать при желании (см catch_unwind), но не для того чтобы "поймать" значение ошибки, а скорее для того чтобы не завершать весь процесс в случае возникновения какого-то бага, а просто залогировать ошибку и продолжить работу. Более того, можно вообще отключить возможность раскрутки стека при панике и тогда её вообще нельзя будет поймать, вместо этого паника сразу завершает процесс. Например, чтобы отключить раскрутку стека в релизной сборке, можно прописать в Cargo.toml следующее:
[profile.release]
panic = "abort"
Это также позволит применять больше оптимизаций и сделает итоговый размер бинарного файла меньше. Лишний код "что делать при возникновении паники" просто выкидывается из сборки и результат компиляции получается практически столь же легковесным как в Си. Поэтому я советую по возможности использовать panic = "abort".
Rust всё же сложнее чем Си?
Есть два аспекта в "сложности" языка. Насколько просто его изучить и насколько просто на нём писать. Си - простой и минималистичный язык, его просто изучить. Вот писать на нём сложно: нужно знать много тонкостей управления памятью, знать много подводных камней чтобы не допускать undefined behavior и тд.
На Rust тоже сложно писать, нужно совладать с borrow checker и статически доказать корректность программы. На самом деле это нужно делать и в Си. Вряд ли вы пишете код на Си с мыслью "да, тут везде undefined behavior, это мне и нужно". Всё же корректность и memory safety итогового кода требуется практически всегда. Так или иначе, нужно доказать (как минимум самому себе) что написанный код корректен, другое дело что в небезопасных языках можно забить на это и просто тестировать внешнее поведение программы, что не гарантирует отсутствие undefined behavior - оно может быть скрыто или проявится позже. Компилятор Rust заставляет вас верифицировать код сразу, поэтому написание кода на нём воспринимается тяжелее.
Но насколько Rust прост в изучении? Если отодвинуть специфику: borrow checker, лайфтаймы, владение - выходит что в Rust не так то много фич, которые нужно изучать. Язык остается сравнительно простым, без нагромождения мусора и ненужного синтаксического сахара. Даже в сравнении с Си Rust может быть проще в некоторых местах. В частности в Rust нет:
- Заголовочных файлов
null(естьstd::ptr::null, но используется как правило только вunsafeкоде, либо для интеропа с тем же Си)- Пре/постфиксных ин/декрементов
i++и++i - Отдельного условного оператора
?:, вместо этого обычныйifexpression - Классического цикла
for (int i = 0; i < n; ++i) {} - Оператора
, do {..} while (..)- Битовых полей в
struct
Но Си по-прежнему менее нагроможден фичами в сравнении с Rust. В Rust есть трейты, const, async, более сложные макросы, итераторы, кложуры (замыкания), более сложный вариант enum и тд. Тем не менее, все эти фичи органично вплетены в язык и активно используются на практике, не являясь мусором или ненужным синтаксическим шумом. Многие ООП языки могут быть столь же или более нагруженными, даже несмотря на наличие автоматического управления памятью.
Конечно, Rust не является простым языком сам по себе. Но его сложность в значительной степени сосредоточена на нескольких фундаментальных механизмах, такие как borrow checker и владение. За их пределами дизайн Rust во многих местах действительно ближе к Си: данные отделены от поведения, нет классов и наследования, нет перегрузки функций, нет исключений, а полиморфизм выражается отдельными механизмами - traits и generics.