if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
return HttpResponse::BadRequest().finish();
}
Этот код — кусок дерьма; кошмар, который вот-вот случится. Чтобы понять, почему и как это исправить, сначала нужно понять главный урок, который мне преподал Rust: силу использования системы типов для обеспечения инвариантов.
Давайте разбираться. В программировании инвариант — это правило или условие, которое всегда должно быть истинным. Например, если мы пишем программное обеспечение для управления банковскими счетами, один из инвариантов может заключаться в том, что баланс никогда не должен быть меньше нуля (предполагая, что овердрафт не разрешен).
struct BankAccount {
// in cents
balance: i32,
}
Но как мы можем соблюсти этот инвариант?
Есть несколько подходов, которые можно разделить на две категории: ручное соблюдение инварианта и его автоматическое соблюдение.
Ручное соблюдение инварианта
Ручное соблюдение инварианта включает в себя:
Код-ревью
Комментарии в коде
Документацию
Проектные документы
Даже устные договоренности, разделяемые между членами команды
Как вы можете представить, такой способ обеспечения довольно хрупок, но у него есть свои применения. Например, представьте себе UI-инвариант, согласно которому любое действие удаления требует подтверждения пользователя. Это было бы очень сложно обеспечить автоматически, поэтому вы просто описываете эти правила в документации и пытаетесь контролировать их соблюдение на код-ревью. Нарушение этого инварианта не будет катастрофическим, поэтому, возможно, в этом случае этого достаточно.
Однако, если мы будем использовать ручной подход в нашем примере с банковским балансом, это быстро приведет нас к краху.
struct BankAccount {
// in cents
// should never be less than 0!
balance: i32,
}
Мы не можем здесь позволить себе нарушение инварианта; нам нужен более надежный метод его обеспечения.
Автоматическое соблюдение инварианта
Включает в себя:
Ассерты в рантайме
Проверки в рантайме
Тестирование
Валидацию ввода
Использование системы типов
Мы затронем все эти подходы, уделяя особое внимание использованию системы типов, который является самым надежным способом соблюдения инвариантов.
Ассерты
Начнем с ассертов как метода автоматического соблюдения инварианта.
struct BankAccount {
balance: i32,
}
impl BankAccount {
fn new(initial_balance: i32) -> Self {
assert!(initial_balance >= 0, "Initial balance cannot be negative");
Self {
balance: initial_balance,
}
}
fn deposit(&mut self, amount: i32) {
assert!(amount >= 0, "Deposit amount cannot be negative");
self.balance += amount;
}
fn withdraw(&mut self, amount: i32) {
assert!(amount >= 0, "Withdrawal amount cannot be negative");
assert!(self.balance >= amount, "Insufficient funds");
self.balance -= amount;
}
}
Мы утверждаем, что начальный баланс должен быть больше или равен нулю. И такие же ассерты ставим в методах deposit
и withdraw
.
Наш инвариант теперь автоматически проверяется через код, но есть несколько проблем. Ассерты проверяются в рантайме, это означает, что разработчики все равно могут написать неправильный код. Кроме того, если ассерт сработает, наша программа вызовет панику и завершится аварийно.
Использование системы типов Rust
Давайте улучшим этот код, используя систему типов Rust. Мы изменим тип баланса с 32-битного знакового целого числа на 32-битное беззнаковое целое число, и баланс теперь в принципе не может быть отрицательным числом. Теперь можно удалить ассерты в функциях new
и deposit
, а также первый ассерт в функции withdraw
.
impl BankAccount {
fn new(initial_balance: u32) -> Self {
Self {
balance: initial_balance,
}
}
fn deposit(&mut self, amount: u32) {
self.balance += amount;
}
fn withdraw(&mut self, amount: u32) {
assert!(self.balance >= amount, "Insufficient funds");
self.balance -= amount;
}
}
Однако нам все еще нужно убедиться, что на счету достаточно средств.
Здесь мы можем воспользоваться важной особенностью системы типов Rust. Мы изменим возвращаемое значение на тип Result
, чтобы учесть эту потенциальную ошибку.
fn withdraw(&mut self, amount: u32) -> Result<u32, String> {
if self.balance >= amount {
self.balance -= amount;
Ok(self.balance)
} else {
Err("Insufficient funds".to_string())
}
}
Затем внутри функции мы выполним простую рантайм-проверку. Поскольку функция withdraw
возвращает тип Result
, она заставит вызывающий код обработать потенциальную ошибку. Мы также можем добавить тесты, чтобы убедиться, что withdraw
работает правильно.
С этим подходом наш код не скомпилируется, или наши тесты завершатся с ошибкой, если инвариант будет нарушен. Это делает наш код гораздо более надежным.
Этот мощный метод проектирования программного обеспечения, использующий систему типов для обеспечения инвариантов, называется type-driven design. Хотя наш предыдущий пример был простым, система типов может обеспечивать соблюдение очень сложных инвариантов, особенно если язык статически типизирован и имеет выразительную систему типов, как Rust.
Почему этот код — кошмар?
В начале видео я сказал, что этот код — кошмар, который вот-вот произойдет:
if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
return HttpResponse::BadRequest().finish();
}
Почему?
#[post("/user/register")]
pub async fn register_user(
form: web::Form<FormData>,
pool: web::Data<PgPool>
) -> HttpResponse {
if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
return HttpResponse::BadRequest().finish();
}
let user = User {
email: form.email.clone(),
password: form.password.clone(),
};
match insert_user(&pool, &user).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
У нас есть API-эндпоинт для создания новых пользователей. Инвариант заключается в том, что электронная почта и пароль должны быть всегда действительными. Мы обеспечиваем это через валидацию ввода: пользователи предоставляют непроверенные данные, и мы вызываем несколько функций валидации, чтобы убедиться, что введенные данные соответствуют нашим требованиям. Только после этого мы сохраняем данные в БД.
Проблема в том, что эти проверки выполняются только один раз — в начале обработчика запросов. Так может ли функция insert_user
безопасно полагать, что электронная почта и пароль действительны?
#[derive(Debug)]
struct User {
pub email: String,
pub password: String,
}
async fn insert_user(pool: &PgPool, user: &User) -> Result<Uuid, sqlx::Error> {
let user_id = Uuid::new_v4();
let password = hash_password(user.password.as_str());
// insert user into database ...
Ok(user_id)
}
Если мы посмотрим на сигнатуру функции в изоляции, нет никакой информации, которая гарантировала бы, что электронная почта и пароль действительны; они определены как простые строки. Эта функция должна верить на слово, что вызывающий код правильно выполнил валидацию перед передачей ввода, и это — рецепт катастрофы.
По мере роста и изменения кода вы можете представить, как проверка валидации случайно удаляется или данные каким-то образом изменяются, и вот мы уже приплыли:
#[post("/user/register")]
pub async fn register_user(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
return HttpResponse::BadRequest().finish();
}
let user = User {
email: form.email.clone(),
password: form.password.clone(),
};
+ // remove sensitive data before logging
+ user.password.clear();
+ dbg!("Registering user: {:?}", &user);
match insert_user(&pool, &user).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
Один из способов предотвратить это — снова выполнить валидацию внутри функции insert_user
:
async fn insert_user(pool: &PgPool, user: &User) -> Result<Uuid, sqlx::Error> {
+ if !is_valid_email(&user.email) || !is_valid_password(&user.password) {
+ // return error...
+ }
let user_id = Uuid::new_v4();
let password = hash_password(user.password.as_str());
// insert user into database ...
Ok(user_id)
}
Однако это вводит ненужную избыточность и чревато ошибками.
Принцип «Не валидировать но парсить»
Вместо этого мы можем воспользоваться принципом проектирования на основе типов: не валидировать но парсить. Вместо того чтобы разбросать функции валидации по всему коду, мы можем парсить пользовательский ввод в новые типы, которые гарантированно будут соблюдать наши инварианты.
Сначала мы создадим два новых типа: Email
и Password
.
pub struct Email(String);
pub struct Password(String);
Оба они являются структурными кортежами, которые оборачивают строковое значение. Оборачивание встроенных типов с нестрогими требованиями в пользовательские типы с более строгими требованиями называется newtype pattern в Rust. В данном случае наши требования (или инварианты) заключаются в том, чтобы электронная почта была правильно отформатирована, а пароль соответствовал требованиям к длине.
Чтобы обеспечить это, мы добавим функцию parse
, которая принимает непроверенную строку в качестве ввода и парсит ее в тип Email
или Password
. Операция парсинга может завершиться неудачей, поэтому мы будем возвращать тип Result
.
impl Email {
pub fn parse(email: String) -> Result<Email, AuthError> {
if !is_valid_email(&email) {
Err(AuthError::ValidationError("Email must be valid".to_string()))
} else {
Ok(Email(email))
}
}
}
impl Password {
pub fn parse(password: String) -> Result<Password, AuthError> {
if !is_valid_password(&password) {
Err(AuthError::ValidationError("Password must be valid".to_string()))
} else {
Ok(Password(password))
}
}
}
Здесь мы используем несколько уникальных особенностей системы типов Rust. Из-за правил видимости Rust внутренняя строка является приватной и недоступной за пределами структуры. И поскольку в Rust нет встроенных или стандартных конструкторов, единственный способ создать экземпляр Email
или Password
— через функцию parse
.
Мы по-прежнему используем те же функции валидации, что и раньше, но теперь логика валидации содержится внутри типа, а состояние валидации сохраняется внутри типа. Мы также можем добавить метод as_str
, чтобы предоставить доступ только для чтения к внутренним строковым данным.
impl Email {
pub fn parse(email: String) -> Result<Email, AuthError> {
// ...
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Password {
pub fn parse(password: String) -> Result<Password, AuthError> {
// ...
}
pub fn as_str(&self) -> &str {
&self.0
}
}
Теперь мы можем обновить структуру User
, чтобы использовать наши новые типы:
#[derive(Debug)]
struct User {
pub email: Email,
pub password: Password,
}
и обновить функцию-регистратор:
#[post("/user/register")]
pub async fn register_user(
form: web::Form<FormData>,
pool: web::Data<PgPool>
-) -> HttpResponse {
- if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
- return HttpResponse::BadRequest().finish();
- }
- let user = User {
- email: form.email.clone(),
- password: form.password.clone(),
- };
+) -> Result<HttpResponse, AuthError> {
+ let email = Email::parse(form.email.clone())?;
+ let password = Password::parse(form.password.clone())?;
+ let user = User::new(email, password);
match insert_user(&pool, &user).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
Теперь любой последующий код может быть уверен, что электронная почта и пароль действительны.
Продвинутые подходы в проектировании, основанном на типах
Проектирование на основе типов — большая тема, и это только верхушка айсберга. Мы только что говорили о принципе не валидировать но парсить и о том, как реализовать его, используя подход нового типа в Rust. Мы также можем использовать более сложные подходы, такие как type state pattern, который позволяет определить различные состояния, в которых может находиться объект, определить конкретные действия для каждого состояния и обеспечить допустимые переходы между состояниями.
Например, пользователь в нашем API может находиться в одном из трех состояний: зритель, редактор или администратор. Сначала мы создадим структуру, представляющую каждое состояние, а затем определим структуру User
, которая является обобщенной по UserRole
, по умолчанию являющейся зрителем.
pub struct Viewer;
pub struct Editor;
pub struct Admin;
pub struct User<UserRole = Viewer> {
pub email: Email,
pub password: Password,
state: PhantomData<UserRole>,
}
impl User {
pub fn new(email: Email, password: Password) -> Self {
Self {
email,
password,
state: PhantomData,
}
}
}
Мы будем хранить обобщение в поле state
, которое использует PhantomData
, чтобы избежать ненужного выделения памяти.
Затем мы можем определить методы, доступные для всех состояний, и методы, специфичные для состояний, такие как метод edit
для редакторов. Мы также можем обеспечить правильные переходы между состояниями: зрители могут быть повышены до редакторов, редакторы до администраторов, а администраторы могут быть понижены до редакторов.
impl User<Viewer> {
pub fn promote(self) -> User<Editor> { /*...*/ }
}
impl User<Editor> {
pub fn edit(&self) { /*...*/ }
pub fn promote(self) -> User<Admin> { /*...*/ }
}
impl User<Admin> {
pub fn demote(self) -> User<Editor> { /*...*/ }
}
Обратите внимание, что мы используем модель владения Rust: эти функции перехода состояния принимают self
в качестве ввода, что перемещает экземпляр в функцию и делает его недоступным в дальнейшем. Это означает, что если экземпляр зрителя будет повышен до редактора, старый экземпляр пользователя более не может быть использован.
fn main() {
let viewer = User::new(
Email::parse("bogdan@email.com".to_string()).unwrap(),
Password::prase("password".to_string()).unwrap(),
);
let editor = viewer.promote();
viewer.get_email(); // error: borrow of moved value 'viewer'
}
Итак, как начать использовать проектирование на основе типов в ваших собственных проектах на Rust? Есть много способов реализовать этот мощный метод проектирования программного обеспечения в Rust. Теоретически вы можете применить некоторые из этих паттернов и в других языках, но Rust делает это особенно практичным благодаря своему устройству системы типов. В других языках эти подходы не всегда практичны, если вообще возможны.
Комментарии (60)
Vladime
04.09.2024 08:49Пример из "Parse don't validate" подан неверно уже в источнике, не сказано, например, что
password
должен быть приватным. Видимо, первоисточник: Parse, Don't validate: An Effective Error Handling Strategy, там изложено правильно.AskePit Автор
04.09.2024 08:49Спасибо за приведенную вами статью, я искренне удивился, что перевожу даже не первичный контент, а вторичный :)
По поводу приватного password: я прочитал первоисточник, но не смог заметить принципиально новой информации о приватности password. В данной статье и оригинальном видео сказано:
Из-за правил видимости Rust внутренняя строка является приватной и недоступной за пределами структуры.
Наверное, вы что-то иное имели в виду? Можете показать конкретный тест в первоисточнике, которого идеологически нехватает в видео Богдана?
Vladime
04.09.2024 08:49Я не знаю, какой из текстов первичен. Но у вас (а также на видео), есть код:
#[derive(Debug)] struct User { pub email: Email, pub password: Password, }
А дальше идет
user.password.clear();
, что также может быть опасно (если реализовать методclear()
и случайно сделатьmut user
). Лучше, в любом случае, поля инкапсулировать. Хотя да, согласен, и по моей ссылке этого нет, приходится додумывать.
Revertis
04.09.2024 08:49Но ведь здесь проблема не решена от слова совсем:
AskePit Автор
04.09.2024 08:49Дико прощу прощения — это кривые руки автора-переводчика. Поправил это, спасибо за указание
Revertis
04.09.2024 08:49То есть убрали код, который мог привести к ошибкам, и решили, что проблема решена???
AskePit Автор
04.09.2024 08:49Этот код не скомпилировался бы, т.к. к
password
теперь нельзя достучаться напрямую — он теперь исключительно readonly черезas_str(&self) -> &str
.Поэтому формально да — все как вы сказали, мы убираем код, который теперь не компилируется, и проблема действительно решена. И даже недосмотр автора перевода не может сломать программу
Revertis
04.09.2024 08:49Так ведь далее возникнет так же задача вывести в лог данные юзера с санитацией. Как будет решаться ЭТА проблема?
AskePit Автор
04.09.2024 08:49Я не вижу принципиальной проблемы в ЭТОЙ проблеме. Если вы грамотно простроите интерфейс класса юзера, вы прекрасно сможете справиться с поставленной задачей.
Вы, конечно можете спросить меня: "А как тогда будет решаться проблема X?", "А проблема Y???", "А проблема Z???!!!1!!1!", и мы с вами сможем в прямом эфире написать прямо здесь, в хабра-комментариях полноценное приложение — благо проблем можно придумать на ходу сколько пожелается. Но неизменным будет, что не всегда решение одной проблемы будет универсально покрывать и решать все остальные проблемы разрабатываемого приложения.
Revertis
04.09.2024 08:49Так изначальный посыл автора был в том, что если кто-то захочет вывести в лог данные юзера, то их надо будет санитайзить, и тут-то возникнет проблема.
Но в итоге никто не показал как решить именно эту проблему. Просто выкинули код санитайзинга и вывода в лог, и решили, что проблема решена, и разработчики никогда не будут с этим сталкиваться.
0x1b6e6
04.09.2024 08:49+2Но в итоге никто не показал как решить именно эту проблему.
Тогда я напишу. Это можно решить переопределением трейта
Debug
для типаPassword
:#[derive(Debug)] struct User { email: Email, password: Password, } #[derive(Debug)] struct Email(String); struct Password(String); impl std::fmt::Debug for Password { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("[hidden]") } } fn main() { let user = User { email: Email("mail@example.com".to_string()), password: Password("my p@$$w0rD".to_string()), }; println!("user = {user:?}"); // user = User { email: Email("mail@example.com"), password: [hidden] } }
Revertis
04.09.2024 08:49Вооот! То есть можно убрать
parse()
и вообще добавить реализациюDebug
дляUser
, а не только дляPassword
.Хотя да, для валидации данных
parse()
лучше оставить.0x1b6e6
04.09.2024 08:49добавить реализацию Debug для User, а не только для Password
Для
User
иEmail
он будет сгенерирован компилятором с помощью#[derive(Debug)]
при объявлении, и их достаточно. А вот для скрытия пароля автоматический не подойдёт, и поэтому дляPassword
он был определен вручную.Хотя да, для валидации данных
parse()
лучше оставить.Лучше оставить, так как тип задумывался что будет содержать только валидное значение. В моем примере я опустил этот метод для простоты. Так как код находится в пределах одного файла, то есть доступ до приватных полей структур в этом файле. В реальном проекте структуры
User
,Email
иPassword
будут в отдельном файле, и создание будет возможно только через методparse()
, который и будет гарантировать валидность содержимого.
qw1
04.09.2024 08:49Зачем делать упор на Rust, если этот же подход применим к любому другому языку.
Хоть C#/Java, хоть C++ или python.Boneyan
04.09.2024 08:49+1По крайней мере некоторые особенности rust делают использование этого подхода более простым. Например использование Result в возвращаемом значении функции. Это частный случай использования enum в rust. При обработке enum помогает pattern matching, которого например в c++ нет:
fn do_dome_operation() -> Result<MyData, MyError> {...} match do_some_operation() { Ok(data) => {...process data...}, Err(err) => {...process error...} }
Плюс, работу с Result упрощает синтаксический сахар в виде `?`.
Или например то что описано в последней главе статьи. Там используются методы (у User), котоыре принимают self в качестве ресивера, то есть после вызова такого метода ресивер поглощается и на нём больше нельзя вызывать методы, это возможно благодаря borrow checker'у в rust. Не знаю можно ли реализовать нечто подобное в c++ или Java, я слышал что в c++ есть какое-то подобие move семантики, но честно говоря в продвинутом c++ не разбираюсь.qw1
04.09.2024 08:49Или например то что описано в последней главе статьи. Там используются методы (у User), котоыре принимают self в качестве ресивера, то есть после вызова такого метода ресивер поглощается и на нём больше нельзя вызывать методы, это возможно благодаря borrow checker'у в rust
Тут тело скрыто, непонятно, происходит ли копирование объекта. Или можно себе на лету поменять тип?
ЕслиUser<Viewer>
превращается вUser<Editor>
пересозданием объекта с копированием полей, то такое нам не надо.impl User<Viewer> { pub fn promote(self) -> User<Editor> { /*...*/ } }
А тут как будто borrow checker притянут за уши.
let viewer = User::new(...); let editor = viewer.promote(); viewer.get_email(); // error: borrow of moved value 'viewer'
Что мешает взять email сразу после
let viewer = User::new
, но использовать послеviewer.promote()
Boneyan
04.09.2024 08:49+2Из статьи не понятно что возвращает get_email(), если это &Email, то трюк с "new(), get_email(), promote(), используем email" не сработает (потому что пока мы владеем ссылкой на email, мы не можем сделать promote()). Если же метод клонирует email, то сработает.
(Не понятно почему в статье присутствует метод get_email() и при этом сам email у User аубличное поле, будем считать что оно приватное на самом деле)
Boneyan
04.09.2024 08:49Я бы для type state pattern скорее привёл такой пример (который я встречал на практике): Билдер, у которого есть обязательные поля, но при этом эти поля можно задать разными способами.
// Наша структура struct MyStruct { data: MyData } // Состояние билдера, которое сообщает что нужно указать data struct NeedsData; // Состояние билдера, которое сообщает что можно билдить struct ReadyToBuild; // билдер struct MyStructBuilder<State> { data: Option<MyData>, pd: PhantomData<State> } impl MyStruct { // возвращает билдер, который требует data fn builder() -> MyStructBuilder<NeedsData> {...} } impl MyStructBuilder<NeedsData> { // поглощает билдер, устанавливает data, возвращает билдер который уже можно билдить fn with_bin_data(self, data: &[u8]) -> MyStructBuilder<ReadyToBuild> {...} // поглощает билдер, читает из файла и устанавливает data, возвращает билдер который уже можно билдить fn with_data_from_file(self, path: &Path) -> MyStructBuilder<ReadyToBuild> {...} } impl MyStructBuilder<ReadyToBuild> { // в таком состоянии уже можно билдить fn build(self) -> MyStruct {...} } impl<T> MyStructBuilder<T> { // здесь можно расположить всякие общие методы билдера }
То есть мы тут обязаны задать у билдера data (удобным нам способом) и только после этого можем вызвать build().
qw1
04.09.2024 08:49Тут снова не показано тело ф-ции
fn with_bin_data(self, data: &[u8]) -> MyStructBuilder<ReadyToBuild>
Оно полностью копирует MyData из билдера в билдер, или копирует ссылку? (кажется, первое, тут же включение структуры MyData в MyStructBuilder, а не ссылки).Ещё интересно, зачем добавляют
pd: PhantomData<State>
В C++, например,MyStructBuilder<State1>
иMyStructBuilder<State2>
уже разные типы, не нужно добавлять поле с типом State, чтобы компилятор считал типы разнымиBoneyan
04.09.2024 08:49Да, там данные полностью перемещаются из билдера в билдер. Что-то вроде:
fn with_bin_data(self, data: &[u8]) -> MyStructBuilder<ReadyToBuild> { ...устанавливаем data... return MyStructBuilder { data: self.data, pd: PhantomData }; }
Есть ли в этом проблема?
PhantomData нужна по единственной причине: rust запрещает добавлять в структуру генерик параметр и при этом не использовать его внутри самой структуры. Поэтому в неё добавляется поле PhantomData<State>. Больше PhantomData ничего не делает.
Boneyan
04.09.2024 08:49P.S. Такой перенос данных от старого билдера к новому (копирование) это в худшем случае мемкопи, в лучшем компилятор поймёт что мы делаем и не будет билдер менять никак. То есть никакого дополнительного выделения динамической памяти производится не будет, если у нас внутри билдера есть поля с динамической памятью.
PrinceKorwin
04.09.2024 08:49Перенос данных делается не через копирование, а через передачу владения.
Boneyan
04.09.2024 08:49Передача владения это концепция на уровне языка. Под капотом в некоторых случаях будет производится копирование.
PrinceKorwin
04.09.2024 08:49Бесспорно. Копирование будет только если вы явно реализовали Copy трейт. Что в обсуждаемом случае не было сделано. Соответственно и копирования не будет.
qw1
04.09.2024 08:49Есть ли в этом проблема?
Только, если бороться за каждую микросекунду. В идеале, в билдере надо поменять поле state и оставить экземпляр тем же. Но так мы не сможем наложить ограничения в compile-time на список методов.
Ниже у 0x1b6e6 есть хорошее решение, когда сами данные лежат отдельно, в том примере - в InnerUser, а сам билдер хранит на них ссылку. Тогда изменения типа билдера - лишь перекладывание ссылки в другой тип билдера (компилятор может это соптимизировать вообще в 0 действий, если ссылка в регистре).
Boneyan
04.09.2024 08:49В целом я согласен, но это уже детали реализации, суть паттерна это не меняет.
PrinceKorwin
04.09.2024 08:49Оно полностью копирует MyData из билдера в билдер, или копирует ссылку?
Как я понимаю ни то, ни другое. Оно переносит данные из одного билдера, в другой.
Пример:
let mut first = Some("ssss") ; let second = first.take(); assert_eq! (first, None) ; assert_eq! (second, "ssss") ;
qw1
04.09.2024 08:49Тут речь о более-менее реальном примере, когда в билдере 30 полей типа int и 20 полей типа string.
PrinceKorwin
04.09.2024 08:49В билдеое эти поля всё равно будут в контейнере Option, т.к. на момент создания инстанса билдера значений для них еще нет.
Поэтому мой пример выше все ещё в силе. И не нужно хранить где-то вовне ссылки и не нужно копирование. Просто передаём права владения дальше по цепочке.
PrinceKorwin
04.09.2024 08:49Не очень понимаю чем вам вариант с передачей владения не нравится. Плюсы:
гарантии от компилятора
zero cost
нет операций копирования/выделения/освобождения памяти
нет проблем с определением времени жизни ссылок
qw1
04.09.2024 08:49Option нравится, если он обёртка с указаталем.
Интересно, как Option понимает, когда держать ссылку, а когда - значение? Например,
Option<int>
он же копирует значение к себе, а не передаёт ссылку от старого владельца.Начали тред с
User<Editor>
и в том примере каждое поле перемещается отдельно, что уже затратно, если полей много.upd. Похоже, для максимально быстрого перемещения нужен синтаксис типа
data: Option<&MyData>
Но тогда сами данные должны лежать где-то отдельно, вне билдеров.
А это та ещё проблема, чтобы дать юзеру красивое API для билдеров.Boneyan
04.09.2024 08:49Option держит ссылку если ему явно сказать: Option<&int> или если храните более долговременные данные, то с умным указателем Option<Rc<int>>.
ИМХО в случае с User<Editor> это скорее всего не затратно, потому что компилятор догадается, что сама структура не меняется (меняется PhantomData только, которая в памяти не хранится, а есть только на этам компиляции) и не будет копировать.
PrinceKorwin
04.09.2024 08:49Интересно, как Option понимает, когда держать ссылку, а когда - значение?
Ссылку или значение - вы ему сами явно указываете: Option<&Value> или Option
Вы, наверное, имели ввиду когда Option забирает владение или когда кладёт в себя копию?
Например, Option он же копирует значение к себе, а не передаёт ссылку от старого владельца.
Если для "int" определен крейт копирования, то будет применен. Если нет, то будет передан по значению. Вы этим управляете.
том примере каждое поле перемещается отдельно, что уже затратно, если полей много.
Перенос владения - он zero cost для рантайма. Ничего у вам там тормозить не будет :
The ownership system is a prime example of a zero-cost abstraction.А вот если вы в Option кладете ссылки, то будьте добры обьяснить компилятору время жизни этих ссылок. И тут вопрос - а в чем профит будет кроме лишнего геммороя?
Ну и не забывайте, что тут будет работа на стеке, без использования хипа. Что положительно сказывается на производительности.
qw1
04.09.2024 08:49Интересно. Вот я написал такой пример:
pub struct MyData { f01: u64, f02: u64, f03: u64, } pub struct Owner1 { state: i32, data: Option<MyData>, } pub struct Owner2 { info: i64, data: Option<MyData>, } fn main() { let owner1 = Owner1 { state: 7, data: Some(MyData { f01: 1, f02: 2, f03: 3 }), }; let owner2 = Owner2 { info: -1, data: owner1.data, }; }
Тут owner1.data и owner2.data имеют размер 32 байта и разные адреса в памяти.
И каким волшебным образом 32 байта скопируются из одного места в другое за zero-cost при перемещении из data.owner1 в data.owner2?
PrinceKorwin
04.09.2024 08:49А ваш пример точно рабочий? У вас там partial move для owner1 случился.
https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=e7edd7b6dfc780f419b9effcb826f626Можете дать ссылку на rust playground с вашим кодом?
PrinceKorwin
04.09.2024 08:49В вашем примере нет ничего, чтобы показало, что произошло копирование.
Если вы после передачи data попробуете получить доступ к owner1.data, то получите ошибку компиляции - данные были перенесены, не скопированны.Это можете проверить заменив &owner2.data на owner1 для size_of_val()
note: move occurs because
owner1.data
has typeOption<MyData>
, which does not implement theCopy
trait
qw1
04.09.2024 08:49Вот я и хочу понять, как это ложится на железо. 32 байта их одного места памяти были "перенесены" в другое без копирования, чисто zero-cost, compile-time.
PrinceKorwin
04.09.2024 08:49Данные не переносятся же.
Физически данные остаются в памяти там, где и были. В этом вся фишка и гордость borrow checker.
Смотрите:
данные изначально разместили на стеке и владение за этой областью памяти компилятор возложил за owner1
после такой вашей инициализации owner2 компилятор отметил у себя, что за этой областью памяти теперь отвечает owner2, а owner1 протух (partial move)
память никуда не копировалась
Далее компилятор следит чтобы не было обрашения к данным владение которым перешло к другому. Если такого кода нет, то скомпилируется и работает. При этом в рантайме в бинарнике не будет ни копирования ни даже if-ов на контроль - zero cost
Но если вы попытаетесь обратиться к owner1.data, то компилятор ругнется.
Никакой магии.
Что тут может быть ещё?
Можно исплементировать трейт Copy и тогда вместо передачи владения действительно будет каждый раз делаться копирование в памяти. Но это позволить работать с owner1.data как ни в чем не бывало
Можно обернуть data в Cow<> и тогда копирование будет не всегда, а только тогда, когда вы захотите изменить значение внутри owner1.data. Cow - Copy On Write
Ну и чтобы owner1 не протух, можно данные из него передать в owner2 через : data : owner1.data.take()
Это сделает owner1.data = None
Сами данные останутся в памяти по тому же адресу, просто у них сменится владелец. И owner1 будет вполне себе нормальным и рабочим, только без данных в data.
qw1
04.09.2024 08:49Я не могу с этим согласиться.
Есть структура owner1, расположенная по некоторому фиксированному адресу, "владеющая" данными, которые лежат внутри этой структуры.Есть структура owner2, расположенная по другому адресу (пример ниже это подтверждает). Она никак не может "владеть" данными, которые физически находятся вне её области памяти. А значит, данные должны быть скопированы при "передаче владения".
Пример на playground: https://play.rust-lang.org/?version=stable&mode=release&edition=2021&gist=7ae6d29329d13268e8ffc8233a5c41fe
Пример показывает, что значения u64 f01, f02, f03 одни и те же, но находятся по разным адресам. Значит, они скопированы в другое место.
PrinceKorwin
04.09.2024 08:49Пример показывает, что значения u64 f01, f02, f03 одни и те же, но находятся по разным адресам.
Ваш пример этого не показывает. Он только показывает, что после операции перемещения у owner2.data те же данные, что были у owner1.data
Вы попробуйте после перемещения (строка 36) обратиться к данным из owner1.data. Если было копирование, то у вас всё получится, но это не так. Компилятор выдаст ошибку.
Вы оперируете понятиями из runtime. Речь же идет про compile time.
Давайте попробую объяснить немного по другому как работает borrow checker в этом случае.
Смотрите. Borrow checker работает с AST деревом чтобы контроллировать права владения.
Пусть будет следующий псевдо-код:
struct MyData(usize); struct Owner1(Option<MyData>); struct Owner2(Option<MyData>); fn main() { let owner1 = Owner1(Some(MyData(1))); let owner2 = Owner2(owner1.data); let s = owner2.0; }
Этот код скомпилируется и упощенное AST значение для owner1 при инициализации будет выглядеть как:
{ "let": { "left": "owner1", "right": { "Owner1": [ { "value": Option: { MyData: [ 1 ] } }, "b_type": "borrowed" ] }, "b_type": "borrowed" } }
Когда вы инициализируете owner2 и вызываете операцию owner1.data компилятор подправляет для owner1 в AST значение на:
{ "let": { "left": "owner1", "right": { "Owner1": [ { "value": none }, "b_type": "moved" ] }, "b_type": "partial_move" } }
Речь про компиляцию. Тут нет никаких "фиксированных адресов". Просто компилятор отмечает факт того, что теперь данные owned1.data не принадлежат структуре owner1.
И код компилируется при этом потому как нет попытки доступа к этим данным.
Но если вы напишете код который попытается получить доступ к owner1.data, то компилятор выдаст ошибку т.к. b_type: moved
А если попробуете owner1 передать целиком куда-то, то тоже получите ошибку т.к. owner1 помечен как b_type: partial_move
Ваш пример ничего не доказывает потому как вы не пытаетесь в нем поработать с owner1.data чтобы показать, что там данные остались, что эти данные были скопированы.
Попробуйте и вы увидете, что компилятор вам этого не позволит.
qw1
04.09.2024 08:49То есть, вы хотите сказать, что структура Owner2 не располагается в памяти непрерывным куском, а компилятор может её виртуально "размазать", расположив первое поле 'info' в одном месте, а второе поле data - в другом месте, где раньше была data от Owner1?
Допустим. Но тут возникает вопрос:
Что происходит, если компилятор встречает функциюfn test(x: &Owner2) { match x.data { Some(ref data) => println!("info={}, data={}", x.info, data.f01), None => {} } }
и её вызов
test(&owner2);
после того, как data перешла из owner1 в owner2.
По моим представлениям, фукнция получает лишь 1 параметр - указатель на начало объекта owner2, и она не имеет никакой информации, где физически лежат данные, переданные во владение owner2, а значит, данные должны быть скопированы внутрь owner2, чтобы функция test смогла к ним обратиться.
Boneyan
04.09.2024 08:49Вопрос, что вы пытаетесь сказать? Что этот паттерн неприменим в случаях когда скопировать 32 байта это дорого? Наверное, но это достаточно редкий случай, мне кажется в подавляющем большинстве производительность при наивной реализации будет более чем достаточной, в остальных случаях уже надо смотреть на конкретную задачу и сиходя из неё придумывать оптимизацию.
qw1
04.09.2024 08:49Я ещё раз перечитал и увидел
Я бы для type state pattern скорее привёл такой пример
Как раз для state очень важно оптимизировать, потому что state может пересоздаваться тысячи раз в циклах (например, state конечного автомата).
0x1b6e6
04.09.2024 08:49+1type state pattern, который позволяет определить различные состояния, в которых может находиться объект, определить конкретные действия для каждого состояния и обеспечить допустимые переходы между состояниями.
pub struct Viewer; pub struct Editor; pub struct Admin; pub struct User<UserRole = Viewer> { pub email: Email, pub password: Password, state: PhantomData<UserRole>, }
На самом деле использовать вот такой способ не очень удобно, если нам вдруг понадобятся новые поля, специфичные для роли.
Лучше немного попотеть и написать отдельную структуру для каждого состояния с базовой структурой. Тогда мы не потеряем свойство "определить конкретные действия для каждого состояния и обеспечить допустимые переходы между состояниями", но при этом появляется гибкость в создании специфичных полей:
pub struct InnerUser { pub email: Email, pub password: Password, } pub struct UserViewer { inner: InnerUser, // .. специфичные поля для наблюдателя } pub struct UserEditor { inner: InnerUser, // .. специфичные поля для редактора } pub struct UserAdmin { inner: InnerUser, // .. специфичные поля для админа }
А чтобы ими было удобно пользоваться, можно реализовать трейт
Deref
для каждого:impl core::ops::Deref for UserViewer { type Target = InnerUser; fn deref(&self) -> &InnerUser { &self.inner } }
Это позволит использовать поля и методы внутренней структуры
InnerUser
как их собственные:fn email_from_viewer(user: &UserViewer) -> String { user.email.as_str().to_string() // ^ // неявно вызывается `Deref::deref` } // Принимает все, что может дать ссылку на `InnerUser`, захватывая оригинальный объект. fn email_from_any_user(user: impl core::ops::Deref<Target = InnerUser>) -> String { user.email.as_str().to_string() // ^ // неявно вызывается `Deref::deref` }
ionicman
Я не силен в Rust, подскажите насчет баланса - вот если оно беззнаковое uin32 и = 0, что будет, если его уменьшить на, например, 5? Ибо в C это приведет просто к переполнению, что будет еще хуже, чем отрицательный баланс (там хоть понятно будет, на сколько ушло).
Ну и проверка типов тоже сомнительна - вы переложили валидацию в тип - окей, можно было, как было сказано выше, поставить валидацию в саму функцию - и это тоже не проблема (не надо вот только про лишние проверки, можно подумать что с типом их не будет на этапе парсинга). Но суть проблемы здесь - отсуствие класса user, где все это должно быть инкапсулировано, те это архитектурная, а не синтаксическая проблема, ИМХО.
PrinceKorwin
Паника в текущем треде исполнения.
Немного не так. Не валидацию, а контроль. Это немного рвзные вещи. К сожалению статья мало этот аспект раскрывает.
И типы это не классы с инкапсулированной валидацией.
ionicman
Естественно, но сути архитектурной проблемы с моей точки зрения это не меняет.
PrinceKorwin
Просто пример с email/password не очень показательный.
Можно привести другой пример. Пусть, для упрощения, мы имеем корзину с товарами. Каждый товар может быть в разном количестве в корзинке. У товаров есть цена. И есть общая цена корзинки.
Здесь мы можем выделить следующие типы:
Корзина
Товар
Кол-во товаров одного типа
Цена за 1 штуку товара
Теперь можно определить возможные отношения и операции между этими типами.
Например:
тип Корзина может содержать в себе N тип Товары
тип Кол-во товаров одного типа можно складывать только с таким-же типом (вычитать и другие мат. операции нельзя, например)
тип Цена может быть суммирован только с таким же типом и может быть умножен на тип Кол-во товаров одного типа
Благодаря этому компилятор не позволит скомпилировать код который случайно попытается умножить цену товара на просто число где бы вы не попытались это сделать. И для этого не нужно будет ни энкапсулировать логику в отдельном классе и следить чтобы эта логика не "утекла" из него. Ни надо расставлять нигде валидации. Компилятор всё проверит за вас.
Имея такие типы в Persistence Layer у вас есть маппинг, что на что. И это дает гарантию, что никто не сможет случайно записать в поле цена число которое не было подсчитано как цена товара.
ionicman
Можно так. Но опять-же это лишь перекладывание сложности.
В приведенном вами случае, вы переложили ее в типы, если сделать класс "Корзина" - то сложность уйдет в нее.
Что до меня - я выберу последнее, так как в жизни корзина куда сложнее устроена (скидки, акции, товары бандлом, огрничение на товарные позиции не больше/не меньше, товарная позиция, которая идет только бандлом с дургим товаром и тд). ИМХО класс в этом случае будет куда более удобным.
Типы, естественно, никто не отменяет, но их я бы сделал просто наиболее универсальными и с ограничением только по edge-случаям.
Попытаюсь переформулировать все мной сказанное - засовывать сильные ограничения в типы и потом с ними танцевать куда сложнее, чем делать проверку при нормальном подходе к ООП.
Но да, в случае с типами будет доп. проверка на уровне компилятора - это плюс. Однако тесты все равно писать)
Сейчас крайности в томже TSe - народ пытается все в типы запихать, везде юзать дженерики - это приводит к такой жести при поддержке потом, что это целая тема для отдельной статьи - не надо так.
PrinceKorwin
А сложность никогда никуда не девается. Просто типы предлагают переложить сложность на:
первичную проработку архитектуры типов
на момент компиляции
Давая при этом гораздо больше гарантий правильности исполнения.
Не совсем. Вам будет не достаточно класса "Корзина". Типы и их гарантии они сквозные - от обработки HTTP запроса, по бизнес-логике и до persist в БД. Вам нужно будет инкапсилуровать логику валидации в класс "Корзина" и в другие классы через которые потенциально эта Корзина ходит.
Каждому своё. Типы и их возможности тоже куда сложнее и многограннее чем моя жалкая попытка их описать.
Полностью согласен. Сделать согласованное непротиворечивое описание типов в большом проекте - это тот еще челендж. Но и бенефита вы получаете гораздо больше чем "делать проверку при нормальном подходе к ООП".
Я много пишу на Java и на Rust. И система типов в Rust меня не один раз спасала от ошибок которые бы Java пропустила. И я больше рад иметь возможность описывать типы тогда когда это имеет смысл, чем не иметь такой возможности.
Ну нет. Вы же объявляя аргумент функции как int не будете делать тест проверяющий, что туда на вход пытаются передать String, это не имеет смысла - компилятор проверит. Вот и с типами так же.
С другой стороны. Имея persistence для корзинки. Как вы напишете тест, что поле total amount корректно во всех сценариях было посчитано? Надо обкладывать тестами и следить чтобы ничего не пропустили в будущем.
В случае с типами вам для этого вообще не нужно будет писать тестов. Гарантии даст компилятор. И эти гарантии будут распространяться также на будущий код.
domix32
Собственно, к языку тут отношения на самом деле примерно никакого. С тем же успехом можно было б взять условный C# и также с голыми интами и строками напилить if ради кривых примеров - суть не поменяется.
Переполнение контролируется пользователем. Если выражение вычислимо на этапе компилияции, то оно не даст скомпилироваться без спецфлагов и будет ругаться на переполнение.
Для динамических данных в дебаге оно будет паниковать, в релизе - переполняться. Для знающих есть wrapping/saturating операции.
ionicman
У меня как раз и был вопрос - в рантайме в раст будет ли паника при уменьшении uint32 ниже нуля, как я понял из ответа - паника будет. Если не будет - тогда зачем все это делать ).
В Си ничего не будет - просто будет число = макисмальное число от типа переменной - ( то что вычли - 1 ).
PrinceKorwin
Код на Rust:
Результат компиляции и исполнения - DEBUG:
Результат компиляции и исполнения - RELEASE:
domix32
В релизе будет переполнение, в дебаге - паника.
AskePit Автор
Про вопрос о вычитании: обычное вычитание через
-
запаникует. При желании в Rust можно воспользоваться встроенными вu32
(и в другие целочисленные типы) методами:Вот как эти виды вычитаний будут себя вести для случаев переполнения:
PrinceKorwin
Только в debug сборке. В release отработает без паники. Пример кода и результата выше по треду.
aiscy
Может быть не совсем верно привязываться к профилю. Все же никто не помешает нам сделать
PrinceKorwin
Да, конечно! Но про такую особенность нужно, конечно знать заранее.