Search
Write a publication
Pull to refresh

Comments 63

Я не силен в Rust, подскажите насчет баланса - вот если оно беззнаковое uin32 и = 0, что будет, если его уменьшить на, например, 5? Ибо в C это приведет просто к переполнению, что будет еще хуже, чем отрицательный баланс (там хоть понятно будет, на сколько ушло).

Ну и проверка типов тоже сомнительна - вы переложили валидацию в тип - окей, можно было, как было сказано выше, поставить валидацию в саму функцию - и это тоже не проблема (не надо вот только про лишние проверки, можно подумать что с типом их не будет на этапе парсинга). Но суть проблемы здесь - отсуствие класса user, где все это должно быть инкапсулировано, те это архитектурная, а не синтаксическая проблема, ИМХО.

вот если оно беззнаковое uin32 и = 0, что будет, если его уменьшить на, например, 5?

Паника в текущем треде исполнения.

вы переложили валидацию в тип - окей

Немного не так. Не валидацию, а контроль. Это немного рвзные вещи. К сожалению статья мало этот аспект раскрывает.

И типы это не классы с инкапсулированной валидацией.

И типы это не классы с инкапсулированной валидацией.

Естественно, но сути архитектурной проблемы с моей точки зрения это не меняет.

Просто пример с email/password не очень показательный.

Можно привести другой пример. Пусть, для упрощения, мы имеем корзину с товарами. Каждый товар может быть в разном количестве в корзинке. У товаров есть цена. И есть общая цена корзинки.

Здесь мы можем выделить следующие типы:

  1. Корзина

  2. Товар

  3. Кол-во товаров одного типа

  4. Цена за 1 штуку товара

Теперь можно определить возможные отношения и операции между этими типами.

Например:

  1. тип Корзина может содержать в себе N тип Товары

  2. тип Кол-во товаров одного типа можно складывать только с таким-же типом (вычитать и другие мат. операции нельзя, например)

  3. тип Цена может быть суммирован только с таким же типом и может быть умножен на тип Кол-во товаров одного типа

Благодаря этому компилятор не позволит скомпилировать код который случайно попытается умножить цену товара на просто число где бы вы не попытались это сделать. И для этого не нужно будет ни энкапсулировать логику в отдельном классе и следить чтобы эта логика не "утекла" из него. Ни надо расставлять нигде валидации. Компилятор всё проверит за вас.

Имея такие типы в Persistence Layer у вас есть маппинг, что на что. И это дает гарантию, что никто не сможет случайно записать в поле цена число которое не было подсчитано как цена товара.

Можно так. Но опять-же это лишь перекладывание сложности.

В приведенном вами случае, вы переложили ее в типы, если сделать класс "Корзина" - то сложность уйдет в нее.

Что до меня - я выберу последнее, так как в жизни корзина куда сложнее устроена (скидки, акции, товары бандлом, огрничение на товарные позиции не больше/не меньше, товарная позиция, которая идет только бандлом с дургим товаром и тд). ИМХО класс в этом случае будет куда более удобным.

Типы, естественно, никто не отменяет, но их я бы сделал просто наиболее универсальными и с ограничением только по edge-случаям.

Попытаюсь переформулировать все мной сказанное - засовывать сильные ограничения в типы и потом с ними танцевать куда сложнее, чем делать проверку при нормальном подходе к ООП.

Но да, в случае с типами будет доп. проверка на уровне компилятора - это плюс. Однако тесты все равно писать)

Сейчас крайности в томже TSe - народ пытается все в типы запихать, везде юзать дженерики - это приводит к такой жести при поддержке потом, что это целая тема для отдельной статьи - не надо так.

Можно так. Но опять-же это лишь перекладывание сложности.

А сложность никогда никуда не девается. Просто типы предлагают переложить сложность на:

  • первичную проработку архитектуры типов

  • на момент компиляции

Давая при этом гораздо больше гарантий правильности исполнения.

В приведенном вами случае, вы переложили ее в типы, если сделать класс "Корзина" - то сложность уйдет в нее.

Не совсем. Вам будет не достаточно класса "Корзина". Типы и их гарантии они сквозные - от обработки HTTP запроса, по бизнес-логике и до persist в БД. Вам нужно будет инкапсилуровать логику валидации в класс "Корзина" и в другие классы через которые потенциально эта Корзина ходит.

Что до меня - я выберу последнее, так как в жизни корзина куда сложнее устроена

Каждому своё. Типы и их возможности тоже куда сложнее и многограннее чем моя жалкая попытка их описать.

Попытаюсь переформулировать все мной сказанное - засовывать сильные ограничения в типы и потом с ними танцевать куда сложнее, чем делать проверку при нормальном подходе к ООП.

Полностью согласен. Сделать согласованное непротиворечивое описание типов в большом проекте - это тот еще челендж. Но и бенефита вы получаете гораздо больше чем "делать проверку при нормальном подходе к ООП".

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

Но да, в случае с типами будет доп. проверка на уровне компилятора - это плюс. Однако тесты все равно писать)

Ну нет. Вы же объявляя аргумент функции как int не будете делать тест проверяющий, что туда на вход пытаются передать String, это не имеет смысла - компилятор проверит. Вот и с типами так же.

С другой стороны. Имея persistence для корзинки. Как вы напишете тест, что поле total amount корректно во всех сценариях было посчитано? Надо обкладывать тестами и следить чтобы ничего не пропустили в будущем.

В случае с типами вам для этого вообще не нужно будет писать тестов. Гарантии даст компилятор. И эти гарантии будут распространяться также на будущий код.

Собственно, к языку тут отношения на самом деле примерно никакого. С тем же успехом можно было б взять условный C# и также с голыми интами и строками напилить if ради кривых примеров - суть не поменяется.

вот если оно беззнаковое uin32 и = 0, что будет, если его уменьшить на, например, 5

Переполнение контролируется пользователем. Если выражение вычислимо на этапе компилияции, то оно не даст скомпилироваться без спецфлагов и будет ругаться на переполнение.

fn main() {
    let mut x = 0usize;
    x -= 1;
}
error: this arithmetic operation will overflow
 --> src/main.rs:3:5
  |
6 |     x -= 1;
  |     ^^^^^^ attempt to compute `0_usize - 1_usize`, which would overflow
  |
  = note: `#[deny(arithmetic_overflow)]` on by default

Для динамических данных в дебаге оно будет паниковать, в релизе - переполняться. Для знающих есть wrapping/saturating операции.

У меня как раз и был вопрос - в рантайме в раст будет ли паника при уменьшении uint32 ниже нуля, как я понял из ответа - паника будет. Если не будет - тогда зачем все это делать ).

В Си ничего не будет - просто будет число = макисмальное число от типа переменной - ( то что вычли - 1 ).

Код на Rust:

fn main() {
    let a: usize = 5;
    let b: usize = a - five();
    let c: usize = b - five();
    println!("Finished with value: {}", c)
}

fn five() -> usize {
    5
}

Результат компиляции и исполнения - DEBUG:

   Compiling playground v0.0.1 (/playground)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running `target/debug/playground`
thread 'main' panicked at src/main.rs:4:20:
attempt to subtract with overflow

Результат компиляции и исполнения - RELEASE:

   Compiling playground v0.0.1 (/playground)
    Finished `release` profile [optimized] target(s) in 0.45s
     Running `target/release/playground`

Finished with value: 18446744073709551611

А если пополнять счет на величину, которая сделает значение больше чем UINT_MAX? Что тогда произойдет?

В релизе будет переполнение, в дебаге - паника.

Про вопрос о вычитании: обычное вычитание через - запаникует. При желании в Rust можно воспользоваться встроенными в u32 (и в другие целочисленные типы) методами:

// Вернет `None` при переполнении
pub const fn checked_sub(self, rhs: u32) -> Option<u32>

// Вычитание с враппингом по модулю
pub const fn wrapping_sub(self, rhs: u32) -> u32

// Вернет wrapped-результат и флаг переполнения
pub const fn overflowing_sub(self, rhs: u32) -> (u32, bool)

// Насыщающее вычитание
pub const fn saturating_sub(self, rhs: u32) -> u32

// Вычитание с паникой при переполнении, даже если отключена проверка на переполнение для обычного вычитания
pub const fn strict_sub(self, rhs: u32) -> u32

Вот как эти виды вычитаний будут себя вести для случаев переполнения:

assert_eq!( 0u32.checked_sub(1), None );
assert_eq!( 0u32.wrapping_sub(1), u32::MAX );
assert_eq!( 0u32.overflowing_sub(1), (u32::MAX, true) );
assert_eq!( 0u32.saturating_sub(1), 0 );
0u32.strict_sub(1); // will panic

Про вопрос о вычитании: обычное вычитание через - запаникует

Только в debug сборке. В release отработает без паники. Пример кода и результата выше по треду.

Может быть не совсем верно привязываться к профилю. Все же никто не помешает нам сделать

[profile.release]
overflow-checks = true

Да, конечно! Но про такую особенность нужно, конечно знать заранее.

Спасибо за приведенную вами статью, я искренне удивился, что перевожу даже не первичный контент, а вторичный :)

По поводу приватного password: я прочитал первоисточник, но не смог заметить принципиально новой информации о приватности password. В данной статье и оригинальном видео сказано:

Из-за правил видимости Rust внутренняя строка является приватной и недоступной за пределами структуры.

Наверное, вы что-то иное имели в виду? Можете показать конкретный тест в первоисточнике, которого идеологически нехватает в видео Богдана?

Я не знаю, какой из текстов первичен. Но у вас (а также на видео), есть код:

#[derive(Debug)]
struct User {
    pub email: Email,
    pub password: Password,
}

А дальше идет user.password.clear(); , что также может быть опасно (если реализовать метод clear() и случайно сделать mut user). Лучше, в любом случае, поля инкапсулировать. Хотя да, согласен, и по моей ссылке этого нет, приходится додумывать.

Но ведь здесь проблема не решена от слова совсем:

Дико прощу прощения — это кривые руки автора-переводчика. Поправил это, спасибо за указание

То есть убрали код, который мог привести к ошибкам, и решили, что проблема решена???

Этот код не скомпилировался бы, т.к. к password теперь нельзя достучаться напрямую — он теперь исключительно readonly через as_str(&self) -> &str.

Поэтому формально да — все как вы сказали, мы убираем код, который теперь не компилируется, и проблема действительно решена. И даже недосмотр автора перевода не может сломать программу

Так ведь далее возникнет так же задача вывести в лог данные юзера с санитацией. Как будет решаться ЭТА проблема?

Я не вижу принципиальной проблемы в ЭТОЙ проблеме. Если вы грамотно простроите интерфейс класса юзера, вы прекрасно сможете справиться с поставленной задачей.

Вы, конечно можете спросить меня: "А как тогда будет решаться проблема X?", "А проблема Y???", "А проблема Z???!!!1!!1!", и мы с вами сможем в прямом эфире написать прямо здесь, в хабра-комментариях полноценное приложение — благо проблем можно придумать на ходу сколько пожелается. Но неизменным будет, что не всегда решение одной проблемы будет универсально покрывать и решать все остальные проблемы разрабатываемого приложения.

Так изначальный посыл автора был в том, что если кто-то захочет вывести в лог данные юзера, то их надо будет санитайзить, и тут-то возникнет проблема.

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

Но в итоге никто не показал как решить именно эту проблему.

Тогда я напишу. Это можно решить переопределением трейта 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] }
}

Вооот! То есть можно убрать parse() и вообще добавить реализацию Debug для User, а не только для Password.

Хотя да, для валидации данных parse() лучше оставить.

добавить реализацию Debug для User, а не только для Password

Для User и Email он будет сгенерирован компилятором с помощью #[derive(Debug)] при объявлении, и их достаточно. А вот для скрытия пароля автоматический не подойдёт, и поэтому для Password он был определен вручную.

Хотя да, для валидации данных parse() лучше оставить.

Лучше оставить, так как тип задумывался что будет содержать только валидное значение. В моем примере я опустил этот метод для простоты. Так как код находится в пределах одного файла, то есть доступ до приватных полей структур в этом файле. В реальном проекте структуры User, Email и Password будут в отдельном файле, и создание будет возможно только через метод parse(), который и будет гарантировать валидность содержимого.

Зачем делать упор на Rust, если этот же подход применим к любому другому языку.
Хоть C#/Java, хоть C++ или python.

По крайней мере некоторые особенности 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++ не разбираюсь.

Или например то что описано в последней главе статьи. Там используются методы (у 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()

Из статьи не понятно что возвращает get_email(), если это &Email, то трюк с "new(), get_email(), promote(), используем email" не сработает (потому что пока мы владеем ссылкой на email, мы не можем сделать promote()). Если же метод клонирует email, то сработает.
(Не понятно почему в статье присутствует метод get_email() и при этом сам email у User аубличное поле, будем считать что оно приватное на самом деле)

Я бы для 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().

Тут снова не показано тело ф-ции
fn with_bin_data(self, data: &[u8]) -> MyStructBuilder<ReadyToBuild>
Оно полностью копирует MyData из билдера в билдер, или копирует ссылку? (кажется, первое, тут же включение структуры MyData в MyStructBuilder, а не ссылки).

Ещё интересно, зачем добавляют pd: PhantomData<State>
В C++, например, MyStructBuilder<State1> и MyStructBuilder<State2> уже разные типы, не нужно добавлять поле с типом State, чтобы компилятор считал типы разными

Да, там данные полностью перемещаются из билдера в билдер. Что-то вроде:

fn with_bin_data(self, data: &[u8]) -> MyStructBuilder<ReadyToBuild>
{
  ...устанавливаем data...
  return MyStructBuilder {
    data: self.data,
    pd: PhantomData
  };
}

Есть ли в этом проблема?

PhantomData нужна по единственной причине: rust запрещает добавлять в структуру генерик параметр и при этом не использовать его внутри самой структуры. Поэтому в неё добавляется поле PhantomData<State>. Больше PhantomData ничего не делает.

P.S. Такой перенос данных от старого билдера к новому (копирование) это в худшем случае мемкопи, в лучшем компилятор поймёт что мы делаем и не будет билдер менять никак. То есть никакого дополнительного выделения динамической памяти производится не будет, если у нас внутри билдера есть поля с динамической памятью.

Перенос данных делается не через копирование, а через передачу владения.

Передача владения это концепция на уровне языка. Под капотом в некоторых случаях будет производится копирование.

Бесспорно. Копирование будет только если вы явно реализовали Copy трейт. Что в обсуждаемом случае не было сделано. Соответственно и копирования не будет.

не важно, реализован Copy или нет, в любом случае данные на стеке всегда копируются побитово, для абсолютно любой структуры без исключения. только Pin препятствует этому. все, что делает Copy, так это позволяет использовать обе переменные, а без него старое значение просто считается невалидным/не инициализирован мы/отсутствующим.

Есть ли в этом проблема?

Только, если бороться за каждую микросекунду. В идеале, в билдере надо поменять поле state и оставить экземпляр тем же. Но так мы не сможем наложить ограничения в compile-time на список методов.

Ниже у 0x1b6e6 есть хорошее решение, когда сами данные лежат отдельно, в том примере - в InnerUser, а сам билдер хранит на них ссылку. Тогда изменения типа билдера - лишь перекладывание ссылки в другой тип билдера (компилятор может это соптимизировать вообще в 0 действий, если ссылка в регистре).

В целом я согласен, но это уже детали реализации, суть паттерна это не меняет.

Оно полностью копирует MyData из билдера в билдер, или копирует ссылку?

Как я понимаю ни то, ни другое. Оно переносит данные из одного билдера, в другой.

Пример:

let mut first = Some("ssss") ;
let second = first.take();
assert_eq! (first, None) ;
assert_eq! (second, "ssss") ;

Тут речь о более-менее реальном примере, когда в билдере 30 полей типа int и 20 полей типа string.

В билдеое эти поля всё равно будут в контейнере Option, т.к. на момент создания инстанса билдера значений для них еще нет.

Поэтому мой пример выше все ещё в силе. И не нужно хранить где-то вовне ссылки и не нужно копирование. Просто передаём права владения дальше по цепочке.

Не очень понимаю чем вам вариант с передачей владения не нравится. Плюсы:

  • гарантии от компилятора

  • zero cost

  • нет операций копирования/выделения/освобождения памяти

  • нет проблем с определением времени жизни ссылок

Option нравится, если он обёртка с указаталем.

Интересно, как Option понимает, когда держать ссылку, а когда - значение? Например, Option<int> он же копирует значение к себе, а не передаёт ссылку от старого владельца.

Начали тред с User<Editor> и в том примере каждое поле перемещается отдельно, что уже затратно, если полей много.

upd. Похоже, для максимально быстрого перемещения нужен синтаксис типа data: Option<&MyData>
Но тогда сами данные должны лежать где-то отдельно, вне билдеров.
А это та ещё проблема, чтобы дать юзеру красивое API для билдеров.

Option держит ссылку если ему явно сказать: Option<&int> или если храните более долговременные данные, то с умным указателем Option<Rc<int>>.
ИМХО в случае с User<Editor> это скорее всего не затратно, потому что компилятор догадается, что сама структура не меняется (меняется PhantomData только, которая в памяти не хранится, а есть только на этам компиляции) и не будет копировать.

Интересно, как Option понимает, когда держать ссылку, а когда - значение?

Ссылку или значение - вы ему сами явно указываете: Option<&Value> или Option

Вы, наверное, имели ввиду когда Option забирает владение или когда кладёт в себя копию?

Например, Option он же копирует значение к себе, а не передаёт ссылку от старого владельца.

Если для "int" определен крейт копирования, то будет применен. Если нет, то будет передан по значению. Вы этим управляете.

том примере каждое поле перемещается отдельно, что уже затратно, если полей много.

Перенос владения - он zero cost для рантайма. Ничего у вам там тормозить не будет :
The ownership system is a prime example of a zero-cost abstraction.

https://web.mit.edu/rust-lang_v1.25/arch/amd64_ubuntu1404/share/doc/rust/html/book/first-edition/ownership.html#:~:text=It accomplishes these goals through,of a zero-cost abstraction.

А вот если вы в Option кладете ссылки, то будьте добры обьяснить компилятору время жизни этих ссылок. И тут вопрос - а в чем профит будет кроме лишнего геммороя?

Ну и не забывайте, что тут будет работа на стеке, без использования хипа. Что положительно сказывается на производительности.

Интересно. Вот я написал такой пример:

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?

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

Это можете проверить заменив &owner2.data на owner1 для size_of_val()

note: move occurs because owner1.data has type Option<MyData>, which does not implement the Copy trait

Вот я и хочу понять, как это ложится на железо. 32 байта их одного места памяти были "перенесены" в другое без копирования, чисто zero-cost, compile-time.

Данные не переносятся же.

Физически данные остаются в памяти там, где и были. В этом вся фишка и гордость borrow checker.

Смотрите:

  • данные изначально разместили на стеке и владение за этой областью памяти компилятор возложил за owner1

  • после такой вашей инициализации owner2 компилятор отметил у себя, что за этой областью памяти теперь отвечает owner2, а owner1 протух (partial move)

  • память никуда не копировалась

Далее компилятор следит чтобы не было обрашения к данным владение которым перешло к другому. Если такого кода нет, то скомпилируется и работает. При этом в рантайме в бинарнике не будет ни копирования ни даже if-ов на контроль - zero cost

Но если вы попытаетесь обратиться к owner1.data, то компилятор ругнется.

Никакой магии.

Что тут может быть ещё?

  1. Можно исплементировать трейт Copy и тогда вместо передачи владения действительно будет каждый раз делаться копирование в памяти. Но это позволить работать с owner1.data как ни в чем не бывало

  2. Можно обернуть data в Cow<> и тогда копирование будет не всегда, а только тогда, когда вы захотите изменить значение внутри owner1.data. Cow - Copy On Write

Ну и чтобы owner1 не протух, можно данные из него передать в owner2 через : data : owner1.data.take()
Это сделает owner1.data = None
Сами данные останутся в памяти по тому же адресу, просто у них сменится владелец. И owner1 будет вполне себе нормальным и рабочим, только без данных в data.

Я не могу с этим согласиться.
Есть структура owner1, расположенная по некоторому фиксированному адресу, "владеющая" данными, которые лежат внутри этой структуры.

Есть структура owner2, расположенная по другому адресу (пример ниже это подтверждает). Она никак не может "владеть" данными, которые физически находятся вне её области памяти. А значит, данные должны быть скопированы при "передаче владения".

Пример на playground: https://play.rust-lang.org/?version=stable&mode=release&edition=2021&gist=7ae6d29329d13268e8ffc8233a5c41fe
Пример показывает, что значения u64 f01, f02, f03 одни и те же, но находятся по разным адресам. Значит, они скопированы в другое место.

Пример показывает, что значения 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 чтобы показать, что там данные остались, что эти данные были скопированы.

Попробуйте и вы увидете, что компилятор вам этого не позволит.

То есть, вы хотите сказать, что структура 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 смогла к ним обратиться.

https://play.rust-lang.org/?version=stable&mode=release&edition=2021&gist=937d18b43d6a61de784b92419b5c394f

Всё. Теперь я вас понял :)
Вы ведь про то, что в структуре owner2 данные должны лежать "сплошняком" и для этого эти данные нужно перенести из owner1.

Посмотрел на результируюший asm и, похоже, вы правы.

Перечитал ещё раз, что с, читает Rust zero cost abstraction. Как теперь понимаю, оно про то, что на гарантии проверки владения ничего не тратится. Но память при этом может быть скопирована в другую структуру.

Спасибо, что указали на это.

Вопрос, что вы пытаетесь сказать? Что этот паттерн неприменим в случаях когда скопировать 32 байта это дорого? Наверное, но это достаточно редкий случай, мне кажется в подавляющем большинстве производительность при наивной реализации будет более чем достаточной, в остальных случаях уже надо смотреть на конкретную задачу и сиходя из неё придумывать оптимизацию.

Сказать, что в реальных билдерах могут быть сотни параметров.

Я ещё раз перечитал и увидел

Я бы для type state pattern скорее привёл такой пример

Как раз для state очень важно оптимизировать, потому что state может пересоздаваться тысячи раз в циклах (например, state конечного автомата).

type 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`
}
Sign up to leave a comment.

Articles