Как Мэтт Годболт «продал» мне Rust (рассказав о C++)
Мэтт Годболт, знаменитый разработчик Compiler Explorer — потрясающий человек, вам стоит найти в вебе и изучить весь созданный им контент. Именно этим и занимался, просматривая Correct by Construction: APIs That Are Easy to Use and Hard to Misuse. Я уже больше двадцати лет работаю с C/C++, поэтому эта тема была мне близка.
Когда я смотрел его доклад, ко мне постоянно приходила мысль: «Да! И именно поэтому в Rust это делается так». После просмотра видео я подумал, что этот доклад — отличный способ понять, как Rust помогает разработчикам не только в безопасности по памяти, и в своей статье я расскажу об этом.
Но прежде нам следует поговорить о поднятых Мэттом проблемах и о том, как он предлагает решать их в C++. Сделайте себе одолжение и посмотрите доклад целиком, а я разберу один из его пунктов.
Что в типе тебе моём
Мэтт начал свой доклад с демонстрации того, как может выглядеть функция, отправляющая поручения биржевым брокерам.
void sendOrder(const char *symbol, bool buy, int quantity, double price)
Прежде чем двигаться дальше, скажу: Мэтт признаёт, что числа с плавающей запятой плохо подходят для цены, а позже рассказывает, как он обычно с этим справляется. Но это хороший пример, так что читайте дальше.
Ещё одно очевидное улучшение, которое следовало бы внести в эту функцию — избавление от bool
при обозначении покупки. Это может вызвать ошибки, и позже сам Мэтт говорит об этом.
Но сначала он обращает внимание на количество (quantity
) и стоимость (price
), подчеркнув, что C++ сильно усложняет защиту вызывающей стороны от ошибок: компилятор допускает и 1000.00 в качестве quantity
, и 100 в качестве price
, не выдавая никаких предупреждений, несмотря на то, что они относятся к другим типам. Он просто выполняет преобразования.
А как насчёт псевдонимов типов?
#include
using Price = double;
using Quantity = int;
void sendOrder(const char *symbol, bool buy, int quantity, double price) {
std::cout << symbol << " " << buy << " " << quantity << " " << price
<< std::endl;
}
int main(void) {
sendOrder("GOOG", false, Quantity(100), Price(1000.00)); // Правильно
sendOrder("GOOG", false, Price(1000.00), Quantity(100)); // Неправильно
}
Увы! И clang 19, и gcc 14 примут этот код без жалоб, даже с -std=c++23 -Wall -Wextra -Wpedantic
, которые я использую для всего кода на C++ из этой статьи! Проведя несколько итераций улучшений, мы придём к следующей версии:
#include
class Price {
public:
explicit Price(double price) : m_price(price) {};
double m_price;
};
class Quantity {
public:
explicit Quantity(unsigned int quantity) : m_quantity(quantity) {};
unsigned int m_quantity;
};
void sendOrder(const char *symbol, bool buy, Quantity quantity, Price price) {
std::cout << symbol << " " << buy << " " << quantity.m_quantity << " "
<< price.m_price << std::endl;
}
int main(void) {
sendOrder("GOOG", false, Quantity(100), Price(1000.00)); // Правильно
sendOrder("GOOG", false, Quantity(-100), Price(1000.00)); // Неправильно
}
У нас есть классы, есть явные конструкторы (это очень важно, или C++ поставит вам подножку!), есть беззнаковые типы... и теперь нам сложно указать Price
там, где нужна Quantity
! Но мы всё равно можем присвоить Quantity
отрицательное значение, и компилятор не выдаст ни единого предупреждения, даже несмотря на то, что мы перешли на беззнаковый тип. Ещё немного магии, и мы сможем избавиться от этого:
#include
#include
class Price {
public:
explicit Price(double price) : m_price(price) {};
double m_price;
};
class Quantity {
public:
template explicit Quantity(T quantity) : m_quantity(quantity) {
static_assert(std::is_unsigned(), "Please use only unsigned types");
}
unsigned int m_quantity;
};
void sendOrder(const char *symbol, bool buy, Quantity quantity, Price price) {
std::cout << symbol << " " << buy << " " << quantity.m_quantity << " "
<< price.m_price << std::endl;
}
int main(void) {
sendOrder("GOOG", false, Quantity(100u), Price(1000.00)); // Правильно
sendOrder("GOOG", false, Quantity(-100), Price(1000.00)); // Неправильно
}
Наконец-то clang (и gcc) будут громко жаловаться на неправильное использование. И для этого понадобился всего лишь шаблонный конструктор, выполняющий static assert во время компиляции. Здорово!
order/order-5.cpp:13:19: error: static assertion failed due to requirement 'std::is_unsigned()': Please use only unsigned types
13 | static_assert(std::is_unsigned(), "Please use only unsigned types");
| ^~~~~~~~~~~~~~~~~~~~~
order/order-5.cpp:26:28: note: in instantiation of function template specialization 'Quantity::Quantity' requested here
26 | sendOrder("GOOG", false, Quantity(-100), Price(1000.00)); // Неправильно
| ^
1 error generated.
Пришлось написать кучу кода, но, по крайней мере, теперь компилятор нас защищает. Всё в порядке, мы никак не сможем использовать quantity
и price
. Или сможем?
Что, если нам нужно передать значение, введённое пользователем в UI, то есть преобразовать его из строки? Нам опять не повезло:
sendOrder("GOOG", false, Quantity(static_cast(atoi("-100"))),
Price(1000.00)); // Неправильно
Этот код не только без проблем скомпилируется, но и не выдаст никаких ошибок в среде выполнения. В результате вы продадите 4294967196 акций и обанкротитесь.
Не очень-то здорово.
В продолжение доклада Мэтт показал ещё несколько магических трюков (и их недостатки), позволяющих выполнять проверки в среде выполнения на случай, если вам нужно полностью защититься от таких ситуаций. Думаю, здесь как раз стоит закончить с C++ и посмотреть, как всё происходит в Rust.
На сцене появляется Rust
Лучше ли Rust? У Rust есть преимущество — он десятки лет учился на всех этих проблемах. И уроки он усвоил хорошо. Давайте взглянем, как сработает наша первая попытка.
fn send_order(symbol: &str, buy: bool, quantity: i64, price: f64) {
println!("{symbol} {buy} {quantity} {price}");
}
fn main() {
send_order("GOOG", false, 100, 1000.00); // Правильно
send_order("GOOG", false, 1000.00, 100); // Неправильно
}
Мы проделали столько работы на C++, не может же быть всё так элементарно?
error[E0308]: arguments to this function are incorrect
--> order/order-1.rs:7:5
|
7 | send_order("GOOG", false, 1000.00, 100); // Неправильно
| ^^^^^^^^^^ ------- --- expected `f64`, found `{integer}`
| |
| expected `i64`, found `{float}`
|
note: function defined here
--> order/order-1.rs:1:4
|
1 | fn send_order(symbol: &str, buy: bool, quantity: i64, price: f64) {
| ^^^^^^^^^^ ------------ --------- ------------- ----------
help: swap these arguments
|
7 | send_order("GOOG", false, 100, 1000.00); // Неправильно
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Ага, и знаете ещё, что? Он даже сообщает нам, какие аргументы мы поменяли местами, и выдаёт другую полезную информацию. Мы как будто попали в будущее! Допустим, но числа всё равно легко перепутать. Наверно, мы тоже можем создать типы, как это делали в C++, чтобы всё было предельно явно? Да, действительно, и давайте ещё добавим защиту от отрицательных значений, перейдя с i64 на u64. Неужели всё так просто?
struct Price(pub f64);
struct Quantity(pub u64);
fn send_order(symbol: &str, buy: bool, quantity: Quantity, price: Price) {
println!("{symbol} {buy} {} {}", quantity.0, price.0);
}
fn main() {
send_order("GOOG", false, Quantity(100), Price(1000.00)); // Правильно
send_order("GOOG", false, Quantity(-100), Price(1000.00)); // Неправильно
}
Да, всё так просто:
error[E0600]: cannot apply unary operator `-` to type `u64`
--> order/order-4.rs:10:40
|
10 | send_order("GOOG", false, Quantity(-100), Price(1000.00)); // Неправильно
| ^^^^ cannot apply unary operator `-`
|
= note: unsigned values cannot be negated
Ну ладно, остаётся ещё случай, когда нам нужно преобразовать число из введённой пользователем строки. Попался, Rust! Ввод в среде выполнения не исправишь во время компиляции. Как ты сможешь улучшить ситуацию по сравнению с C++?
struct Price(pub f64);
struct Quantity(pub u64);
fn send_order(symbol: &str, buy: bool, quantity: Quantity, price: Price) {
println!("{symbol} {buy} {} {}", quantity.0, price.0);
}
fn main() {
send_order("GOOG", false, Quantity(100), Price(1000.00)); // Правильно
send_order(
"GOOG",
false,
Quantity("-100".parse::()),
Price(1000.00),
); // Неправильно
}
А как насчёт того, чтобы принудить пользователя обрабатывать потенциально плохие преобразования, вызванные тем, что целевой тип не может представить число, введённое в строке? Поможет нам Rust в этом?
error[E0308]: mismatched types
--> order/order-6.rs:13:18
|
13 | Quantity("-100".parse::()),
| -------- ^^^^^^^^^^^^^^^^^^^^^ expected `u64`, found `Result<u64, ParseIntError>`
| |
| arguments to this struct are incorrect
|
= note: expected type `u64`
found enum `Result<u64, ParseIntError>`
note: tuple struct defined here
--> order/order-6.rs:2:8
|
2 | struct Quantity(pub u64);
| ^^^^^^^^
help: consider using `Result::expect` to unwrap the `Result<u64, ParseIntError>` value, panicking if the value is a `Result::Err`
|
13 | Quantity("-100".parse::().expect("REASON")),
| +++++++++++++++++
error: aborting due to 1 previous error
Да, он помогает, я не могу слепо преобразовывать типы.
Как же он чертовски хорош.
Этой ошибки мне, пользователю API, должно быть достаточно, чтобы я смог изящно обработать эту возможность, и, возможно, передать ошибку в UI, сообщив «отрицательные числа вводить нельзя». Но что, если я просто добавлю этот .expect()
, о котором говорит мне компилятор, и после этого пользователь введёт отрицательное число? Произойдёт вылет программы в среде выполнения. Это лучше, чем банкротство? Думаю, да.
> ./order-6
GOOG false 100 1000
thread 'main' panicked at order/order-6.rs:16:18:
Quantities cannot be negative: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
В заключение
И знаете, что самое интересное во всей этой статье? Rust знаменит своей безопасностью по памяти, но я ни разу не упомянул её. Да, вы можете заявить, что когда мы путаем integer
и float
— это проблема памяти, но это уже явная натяжка для обычного определения безопасности по памяти.
Из этого упражнения мы узнали, что правильно спроектированный язык может защитить нас от ошибок в гораздо большей степени, нежели простое предотвращение использования данных после освобождения памяти или гонок данных. Архитектура языка может снизить когнитивную нагрузку благодаря тому, что она не заставляет вас думать, как защитить код от простейших ошибок — с этим справится сам язык.
Впрочем, будем откровенны. Большая часть сэкономленных мыслительных усилий новичка в Rust всё равно будет потрачена на то, чтобы убедить borrow checker в правильности действий разработчика. Но со временем вы освоитесь, я гарантирую. Однако не буду врать: первые несколько месяцев работы с borrow checker будут тяжёлыми.
Мы закончили с этим? Не совсем. В своём докладе Мэтт осветил ещё пару тем, о которых я поговорю в будущих статьях; одна из них связана с самым наболевшим для меня в C++. А если вы считаете, что «если у C++ были десятки лет для обучения, то в нём наверняка есть и столь же хорошо спроектированные фичи», то... скажу, что вас ждёт приятный сюрприз.
Ещё раз повторюсь — изучайте контент Мэтта Годболта. Слушайте, что говорит этот человек, читайте всё написанное им, посмотрите все видео с его докладами на YouTube и попробуйте поработать с его Compiler Explorer. Вы многому научитесь и при этом получите удовольствие!