Программирование PC Rust: использование структуры для взаимосвязанных данных Sun, September 08 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

Rust: использование структуры для взаимосвязанных данных Печать
Добавил(а) microsin   

Во многих языках программирование есть такое понятие как struct, или структура. Это пользовательский тип данных, который позволяет вам упаковать друг с другом несколько логически связанных данных в осмысленную группу. Если вы знакомы с объектно-ориентированным языком программирования, то знаете, что структура это как бы объект со своими атрибутами данных. В этой главе (перевод [1]) мы сравним кортежи (tuple) со структурами (struct), опираясь на то, что вы уже знаете, и продемонстрируем, когда структура будет более подходящим способом группировать данные.

Мы продемонстрируем, как определять и создавать структуры. Обсудим как определять связанные функции, особенно тип связанных функций, которые называются методами, чтобы задать поведение, связанное с типом структуры. Структуры и перечисления (enum, перечисления обсуждаются в главе 6) это составляющие блоки для создания новых типов в вашей программе, чтобы в полной мере применять достоинства проверок типизации языка Rust на этапе компиляции.

[Определение структуры и её создание]

Структуры подобны кортежам (см. секцию "Тип кортеж"), они оба содержат несколько связанных значений. Части структуры, как и кортежи, могут быть различных типов. В отличие от кортежей, в структуре вы обозначаете именем и типом каждую часть данных, чтобы было ясно, что собой представляет каждое значение в структуре. Добавление этих имен дает структурам большую гибкость по сравнению с кортежами: для доступа к данным структуры нет необходимости сосредотачиваться на порядке следования значений, как это нужно делать с использованием кортежа.

Чтобы определить структуру мы вводим ключевое слово struct и имя всей структуры. Имя структуры должно отражать особенность группы данных, объединенных в эту структуру. Затем внутри фигурных скобок мы определяем имена и типы частей данных структуры, которые называют полями структуры. Например, листинг 5-1 показывают структуру, которая сохраняет информацию об учетной записи пользователя.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

Листинг 5-1. Пользовательское определение структуры.

Чтобы использовать структуру после того, как она была определена, мы создаем экземпляр этой структуры путем указания конкретных значений для каждого её поля. Экземпляр структуры создается путем указания имени структуры, после чего внутри фигурных скобок через запятую перечисляются пары key: value, где key это имя поля структуры, а value это значение, сохраняемое для поля. Нам не обязательно указывать поля в том же порядке, в каком они были определены в структуре. Другими словами, определение структуры создает общий шаблон для группы связанных данных, а создание экземпляра структуры это резервирование в памяти места для группы данных по этому шаблону, и заполнение этой группы конкретными данными. Например, мы можем определить какого-то пользователя, как показано в листинге 5-2:

fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };
}

Листинг 5-2. Создание экземпляра структуры User.

Чтобы получить определенное значение из структуры, используется нотация точки. Например, для доступа к адресу email пользователя user1 мы используем user1.email. Если экземпляр мутируемый, мы можем поменять значение, используя нотацию точки и присвоение необходимого поля. Листинг 5-3 показывает, как поменять значение поля email мутируемого экземпляра структуры User.

fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };

user1.email = String::from("anotheremail@example.com"); }

Листинг 5-3. Изменение значения поля email экземпляра структуры User.

Обратите внимание, что должен быть мутируемым весь экземпляр структуры. Rust не позволяет помечать мутируемыми только отдельные поля. Как с любым выражением, мы можем конструировать новый экземпляр структуры как последнее выражение в теле функции, чтобы неявно возвратить новый экземпляр.

Листинг 5-4 показывает функцию build_user, которая возвратит экземпляр User с указанными email и username. Поле active получает значение true, и sign_in_count получает значение 1.

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}

Листинг 5-4. Функция build_user, которая берет email и username, и возвращает экземпляр User.

Имеет смысл давать такие же имена параметрам функции, как и имена полей структуры, однако повторять эти имена кажется несколько утомительным. Если у структуры есть большое количество полей, то повторение каждого имени станет еще более раздражающим. К счастью, существует удобное сокращение, синтаксис field init shorthand.

Использование field init shorthand. Поскольку имена параметров и имена полей структуры такие же, как в листинге 5-4, мы можем использовать синтаксис сокращенной инициализации полей (field init shorthand), чтобы переписать код функции build_user, чтобы она делала абсолютно то же самое, но без необходимости повторения username и email, как показано в листинге 5-5.

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

Листинг 5-5. Функция build_user, которая использует field init shorthand, потому что есть совпадение имен параметров функции и соответствующих полей структуры.

Здесь мы создаем новый экземпляр структуры User, в которой есть поле с именем email. Мы хотим установить значение поля email в значение параметра email функции build_user. Поскольку у поля email и параметра email имена одинаковые, то можно просто указать email вместо email: email.

Создание экземпляра из других экземпляров, используя синтаксис struct update. Часто полезно создать новый экземпляр структуры, который содержит большинство таких же значений другого экземпляра, но для некоторых полей должны быть изменения. Это можно делать с помощью синтаксиса struct update.

Сначала в листинге 5-6 мы покажем, как создать новый экземпляр User user2 просто, без синтаксиса update. Мы установим новое значение для email, но для остальных полей будут те же значения, что и в экземпляре user1, который мы создали в листинге 5-2.

fn main() {
    // -- вырезано --

let user2 = User { active: user1.active, username: user1.username, email: String::from("another@example.com"), sign_in_count: user1.sign_in_count, }; }

Листинг 5-6. Создание нового экземпляра User обычным способом, используя все значения из user1.

С помощью синтаксиса struct update мы можем достичь того же эффекта, но с меньшим количеством кода, как показано в листинге 5-7. Синтаксис .. обозначает, что остальные поля неявно должны быть установлены в такое же значение, что и в указанном экземпляре user1.

fn main() {
    // -- вырезано --

let user2 = User { email: String::from("another@example.com"), ..user1 }; }

Листинг 5-7. Использование синтаксиса struct update, чтобы установить другое значение email для создаваемого нового экземпляра User, без необходимости ручного заполнения всех остальных полей из user1.

Код в листинге 5-7 также создает экземпляр user2, у которого другое значение email, но значение остальных полей username, active и sign_in_count такие же, как у экземпляра user1. Здесь ..user1 должен появляться последним, чтобы указать, что любые остальные поля должны получить копии соответствующих полей в user1, однако мы можем указывать предыдущие назначаемые поля в любом порядке, как хотим, не обращая внимания на порядок следования полей в определении структуры.

Обратите внимание, что синтаксис struct update использует оператор = как присваивание; причина в том, что происходит перемещение данных (move data), как мы говорили в секции "Переменные и данные, взаимодействующие с перемещением" главы 4 (см. [2]). В этом примере нам больше не будет дозволено использовать user1 после создания user2, потому что String в поле username экземпляра user1 переместилось в user2. Если бы мы дали user2 новые значения String для обоих полей email и username, используя только значения active и sign_in_count из user1, то user1 все еще был бы достоверным после создания user2. Оба типа active и sign_in_count реализуют трейт Copy, так что для них применимо поведение, которое обсуждалось в секции "Stack-Only Data: Copy" главы 4 (см. [2]).

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

Чтобы определить tuple struct, начните определение с ключевого слова struct и добавьте имя структуры, за которым идут типы в кортеже. Например, здесь мы создали две структуры кортежа с именами Color и Point:

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() { let black = Color(0, 0, 0); let origin = Point(0, 0, 0); }

Обратите внимание, что здесь значения black и origin имеют разные типы, потому что их экземпляры получили разные структуры кортежа. Каждая структура, которую вы определили, имеют свой тип, даже когда поля в структуре могут иметь такие же типы. Например функция, которая принимает тип Color в качестве параметра, не может принять тип Point в аргументе, хотя оба эти типа построены из трех значений i32. В противном случае экземпляры структуры кортежа подобны кортежам в том, что вы можете деструктурировать их в отдельные части, и вы можете использовать ., за которой идет индекс, для доступа к отдельному значению.

Unit-Like структуры без полей. Вы также можете определить структуры, у которых вообще нет никаких полей! Такие структуры называются unit-like структурами, потому что они ведут себя подобно (), типу unit, который мы упоминали в секции "Тип Tuple" главы 3 (см. [3]). Unit-like структуры могут быть полезны, когда вам нужно реализовать трейт некоторого типа, но без каких-либо данных, которые вы хотели бы сохранить в самом типе. Мы будем обсуждать трейты в главе 10. Ниже дан пример декларирования и создания экземпляра unit-структуры с именем AlwaysEqual:

struct AlwaysEqual;

fn main() { let subject = AlwaysEqual; }

Для определения AlwaysEqual, мы используем ключевое слово struct, произвольное имя, и затем точку с запятой. Не нужны ни фигурные скобки, ни круглые! Затем мы можем получить экземпляр AlwaysEqual в переменной subject обычным образом: используя определенное нами имя, без каких-либо фигурных скобок или фигурных скобок. Представьте, что мы позже реализуем поведение для этого типа такое, что каждый экземпляр AlwaysEqual всегда равен каждому экземпляру любого другого типа, возможно для того, чтобы получить известный результат с целью тестирования. Для такого поведения нам не нужны никакие данные! В главе 10 мы посмотрим, как определить трейты и реализовать их на любом типе, включая unit-like структуры.

Ownership данных структуры. В определении структуры User листинга 5-1 мы использовали owned-тип String вместо типа слайса строки &str. Это осознанный выбор, потому что мы хотим, чтобы каждый экземпляр этой структуры владел своими данными, и чтобы данные экземпляра структуры были действительны, пока действительна вся структура.

Также есть возможность для структур сохранять ссылки на данные, которыми владеет кто-то другой, но для этого нужно использовать время жизни (lifetime), фичу Rust, которую мы будем обсуждать в главе 10. Lifetime гарантирует, что данные, на которые ссылается структура, будут достоверны, пока структура является действительной. Допустим, вы пытаетесь сохранить ссылку в структуре без указания lifetime, примерно как показано ниже. Это не сработает:

struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() { let user1 = User { active: true, username: "someusername123", email: "someone@example.com", sign_in_count: 1, }; }

Компилятор пожалуется на то, что необходимы спецификаторы lifetime:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier --> src/main.rs:4:12 | 4 | email: &str, | ^ expected named lifetime parameter | help: consider introducing a named lifetime parameter | 1 ~ struct User<'a> { 2 | active: bool, 3 | username: &str, 4 ~ email: &'a str, |

For more information about this error, try `rustc --explain E0106`. error: could not compile `structs` (bin "structs") due to 2 previous errors

В главе 10 мы обсудим, как исправить эти ошибки, чтобы вы могли сохранять ссылки в структурах, но пока что мы исправим подобные ошибки, используя owned-типы наподобие String вместо ссылок наподобие &str.

[Пример программы, использующей структуры]

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

Создайте новый проект бинарника с именем rectangles с помощью Cargo. Ширина и высота прямоугольника будет указываться в точках, и программа будет вычислять площадь прямоугольника. Листинг 5-8 показывает для этого следующую короткую программу:

fn main() {
    let width1 = 30;
    let height1 = 50;

println!( "Площадь прямоугольника равна {} квадратных точек.", area(width1, height1) ); }

fn area(width: u32, height: u32) -> u32 { width * height }

Листинг 5-8. Вычисление площади прямоугольника, указанного отдельными переменными для ширины (width) и высоты (height).

Запустим эту программу командой cargo run:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
Площадь прямоугольника равна 1500 квадратных точек.

Этот код делает свою работу хорошо и правильно, но его можно упростить и сделать более удобочитаемым. Пока что проблема заключается в сигнатуре функции area:

fn area(width: u32, height: u32) -> u32 {

Предполагается, что функция area вычисляет площадь одного прямоугольника. Но функция, которую мы написали, принимает два параметра, и нигде в нашей программе не ясно, что эти параметры связаны. Было бы более читабельно и более управляемо группировать ширину и высоту вместе. Мы уже обсуждали один из способов сделать это в секции "Тип Tuple" главы 3 (см. [3]).

Рефакторинг на кортежах. Листинг 5-9 показывает другую версию нашей программы, которая использует кортеж для ширины и высоты.

fn main() {
    let rect1 = (30, 50);

println!( "Площадь прямоугольника равна {} квадратных точек.", area(rect1) ); }

fn area(dimensions: (u32, u32)) -> u32 { dimensions.0 * dimensions.1 }

Листинг 5-9. Указание ширины и высоты прямоугольника через кортеж.

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

Для вычисления площади можно путать ширину и высоту, но если мы хотим нарисовать прямоугольник на экране, то это уже имеет значение! Нам надо помнить, что кортеж по индексу 0 это ширина, и кортеж по индексу 1 это высота. Ещё сложнее будет другим разработчикам, которые должны иметь это в виду, когда будут использовать ваш код. Поскольку наш код теперь не очень-то отражает его смысл, стало проще в нем допускать ошибки.

Рефакторинг на структурах: добавление смысла. Мы используем структуры для добавления смысла в программу, присваивая данным метки. Можно преобразовать кортеж в структуру, где будет дано имя как структуре целиком, так и её частям (ширине и высоте), как показано в листинге 5-10.

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() { let rect1 = Rectangle { width: 30, height: 50, };

println!( "Площадь прямоугольника равна {} квадратных точек.", area(&rect1) ); }

fn area(rectangle: &Rectangle) -> u32 { rectangle.width * rectangle.height }

Листинг 5-10. Определение структуры Rectangle.

Здесь мы определили структуру с имеем Rectangle. Внутри фигурных скобок мы определили поля width и height, оба типа u32. Затем в main, we мы создаем отдельный экземпляр Rectangle, у которого width 30 (ширина) и height 50 (высота).

Наша функция area теперь определена с одним параметром, который называется rectangle, и у этого параметра тип немутируемого заимствования (immutable borrow) экземпляра структуры Rectangle. Как упоминалось в главе 4 (см. [2]), мы хотим взять заимствование структуры вместо приема владения над ним. При этом функция main сохраняет свои права владения (ownership) над переменной rect1, и может продолжать её использовать. Именно для этой цели мы использовали & в параметре сигнатуры функции и при её вызове.

Функция area обращается к полям width и height экземпляра структуры Rectangle (обратите внимание, что доступ к полям заимствованного экземпляра структуры не делает перемещения значений полей, поэтому часто встречаются заимствования структур). Сигнатура нашей функции для area теперь ясно говорит, что мы имеем в виду: вычисляется площадь (area) прямоугольника (Rectangle), используя его поля ширины (width) и высоты (height). Это означает, что ширина и высота связаны друг с другом, и их величинам даны описательные имена вместо индексов 0 и 1, как это было с кортежем. В контексте понимания и ясности кода это победа.

Добавление полезного функционала через производные признаки (Derived Traits). Может быть полезным выводить на печать экземпляр Rectangle, когда мы отлаживаем нашу программу и видим значения всех его полей. Листинг 5-11 пытается использовать макрос println!, который мы использовали в предыдущих главах [1]. Однако это не сработает.

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() { let rect1 = Rectangle { width: 30, height: 50, };

println!("rect1 is {}", rect1); }

Листинг 5-11. Попытка напечатать экземпляр Rectangle (неудачная).

Если мы скомпилируем этот код, то получим ошибку со следующим основным сообщением:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

Макрос println! может многое при форматировании вывода, и по умолчанию фигурные скобки говорят println! использовать форматирование, известное как Display: вывод, предназначенный непосредственно для внедрения пользователем. Примитивные типы, которые мы видели до сих пор, имеют готовые реализации Display по умолчанию. Однако со структурами для println! нет необходимой информации, как нужно отображать объект: должны ли быть запятые, или нет? Печатать или нет фигурные сборки? Надо ли показывать все поля? Из-за этой неопределенности Rust не пытается угадать, что нам нужно, и для структуры нет предоставленной реализации Display для использования println! и формата {}.

Если мы продолжим читать сообщение об ошибке, то найдем следующее полезное замечание:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

Попробуйте этот совет, вызовите макрос примерно так: println!("rect1 is {:?}", rect1);. Если поместить спецификатор :? в фигурных скобках, то это укажет для println! использовать формат вывода, который называется Debug. Трейт Debug позволит нам напечатать нашу структуру способом, который полезен для разработчиков, это даст возможность увидеть значение объекта при отладке кода.

Если скомпилировать код еще раз, уже с таким изменением {:?}, то все еще будет наблюдаться ошибка:

error[E0277]: `Rectangle` doesn't implement `Debug`
   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

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

#[derive(Debug)]
struct Rectangle { width: u32, height: u32, }

fn main() { let rect1 = Rectangle { width: 30, height: 50, };

println!("rect1 is {rect1:?}"); }

Листинг 5-12. Добавление атрибута derive для трейта Debug и печать экземпляра Rectangle, используя отладочное форматирование.

Теперь, когда мы запустим нашу программу, ошибок больше не будет, и мы увидим следующий вывод:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

Отлично! Хотя это не самый лучший вывод, но он показывает значения всех полей нашего экземпляра структуры, что определенно может помочь в отладке. Когда у нас есть большие структуры, было бы полезнее для чтения получить более простой вывод; в таких случаях можно использовать {:#?} вместо {:?} в формате println!. В следующем примере вывода использовался стиль {:#?}:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Другой способ печати значения с помощью формата Debug - использование макроса dbg!, который берет во владение выражение (в отличие от println!, который принимает ссылку). Макрос dbg! напечатает имя файла и номер строки в коде, где использовался вызов dbg!, вместе с результатом значения выражения, и возвратит владение значением.

Примечание: вызов макроса dbg! производит печать в стандартный поток ошибок консоли (stderr), в отличие от println!, который производит печать в поток стандартного вывода консоли (stdout). Более подробно про stderr и stdout мы поговорим в секции "Writing Error Messages to Standard Error Instead of Standard Output" главы 12.

Вот пример, где нас интересует значение, которое присваивается полю width, а также значение всей структуры в rect1:

#[derive(Debug)]
struct Rectangle { width: u32, height: u32, }

fn main() { let scale = 2; let rect1 = Rectangle { width: dbg!(30 * scale), height: 50, };

dbg!(&rect1); }

Мы можем поместить dbg! вокруг выражения 30 * scale, и так как dbg! возвратит владение над значением выражения, поле width получит такое же значение, как будто у нас здесь вообще не было вызова dbg!. Мы не хотим, чтобы dbg! взял владение над rect1, поэтому мы используем ссылку на rect1 в следующем вызове. Вывод этого примера будет примерно таким:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

Здесь мы можем видеть, что первый вывод произошел из строки 10 модуля src/main.rs, где мы отлаживали выражение 30 * scale, и его результирующее значение 60 (форматирование Debug, реализованное для целых чисел, печатает только их значение). Вызов dbg! на строке 14 модуля src/main.rs выводит значение &rect1, что соответствует структуре Rectangle. Макрос dbg! может быть реально полезен, когда вам нужно понять, что реально происходит в вашем коде.

В дополнение к трейту Debug, Rust предоставляет для нас несколько трейтов для использования вместе с атрибутом derive, что может добавить полезное поведение для наших пользовательских типов. Эти трейты и их поведение перечислены в Appendix C. Мы рассмотрим в главе 10, как реализовать эти трейты самостоятельно с необходимым поведением. Также есть множество других атрибутов кроме derive, для дополнительной информации см. секцию "Attributes" онлайн-руководства Rust [4].

Наша функция area очень специфическая: она только лишь вычисляет площадь прямоугольника. Было бы полезно связать это поведение именно со структурой Rectangle, потому что функция area не может работать ни с каким другим типом. Давайте посмотрим, как мы можем еще улучшить наш код, превратив функцию area в метод, определенный на нашем типе Rectangle.

[Синтаксис метода]

Методы очень подобны функциям: мы также декларируем их с ключевым словом fn и именем, они также могут иметь (или не иметь) параметры, и могут возвращать значение, и они также содержат некий код, который будет работать, когда мы вызовем где-нибудь этот метод. Но в отличие от обычных функций, методы работают в контексте структуры (или перечисления enum, или trait-объекта, что будет рассмотрено в главе 6 и главе 17 соответственно), и у методов их первый параметр всегда self, который представляет экземпляр структуры, на которой метод вызывается.

Определение методов. Давайте поменяем функцию area, чтобы у неё параметром был экземпляр Rectangle, и сделаем её методом, определенным на структуре Rectangle, как показано в листинге 5-13.

#[derive(Debug)]
struct Rectangle { width: u32, height: u32, }

impl Rectangle { fn area(&self) -> u32 { self.width * self.height } }

fn main() { let rect1 = Rectangle { width: 30, height: 50, };

println!( "Площадь прямоугольника равна {} квадратных точек.", rect1.area() ); }

Листинг 5-13. Определение метода area на структуре Rectangle.

Чтобы определить функцию в контексте Rectangle, мы начинаем с ключевого слова impl (сокращение от implementation, т. е. реализация), которое создает блок кода для Rectangle. Все, что находится внутри этого блока impl, будет ассоциировано с типом Rectangle. Затем мы переместили функцию area внутрь фигурных скобок и поменяли в её сигнатуре первый (и в нашем случае единственный) параметр на self, и все что с ним связанное в теле функции. Из main мы вызываем функцию area и передаем в неё rect1 в качестве аргумента, используя при этом синтаксис метода на экземпляре нашей структуры Rectangle. Имя метода указывается после имени экземпляра через точку, далее идут круглые скобки и в них аргументы (если они есть еще кроме self).

В сигнатуре для area, мы использовали &self вместо rectangle: &Rectangle. Внутри блока impl тип Self это псевдоним для типа, которому принадлежит блок impl (в нашем примере Rectangle). Методы обязательно должны иметь первый параметр с именем self, который имеет тип Self. Обратите внимание, что вам все еще нужно использовать & перед self, чтобы показать, что этот метод заимствует экземпляр Self, аналогично тому, как мы делали в параметре rectangle: &Rectangle функции area. Методы могут брать владение над self, заимствуя self немутируемо, как мы делаем здесь, или заимствуя self мутируемо, точно так же, как это можно делать с любым другим параметром.

Мы выбрали здесь использовать &self по той же причине, с которой использовали &Rectangle в версии для функции: нам не нужно брать владение, и нам нужно только читать данные в структуре, ничего записывать в неё не нужно. Если бы мы захотели поменять экземпляр, на котором делается вызов метода, как часть того, что делает метод, то использовали бы в качестве первого параметра &mut self. Наличие метода, который принимает вместо ссылки владение экземпляром, используя self в качестве первого параметра, встречается редко; эта техника обычно используется, когда метод преобразует себя во что-то другое, и вы хотите запретить вызывающему коду использовать исходный экземпляр после преобразования.

Основная причина использования методов вместо функций состоит в организации кода, помимо предоставления синтаксиса метода и отсутствия необходимости повторять тип self в сигнатуре каждого метода. Мы помещаем все вещи, которые можем сделать с экземпляром типа, в один блок impl вместо того, чтобы создавать разрозненные функции и заставлять потенциальных пользователей нашей библиотеки искать возможности Rectangle в разных частях кода.

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

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() { let rect1 = Rectangle { width: 30, height: 50, };

if rect1.width() { println!("У прямоугольника ненулевая ширина, она равна {}", rect1.width); } }

Здесь мы решили сделать метод width, который возвращает true если значение поля width в экземпляре больше 0, и false если значение равно 0: мы можем использовать поле внутри метода с таким же именем для любых целей. В main, когда мы следуем rect1.width со скобками, Rust знает, что мы имеем в виду метод width, а не поле структуры. Когда скобок нет, Rust знает, что мы имеем в виду поле width.

Часто, но не всегда, когда мы даем методу такое же имя, что и у поля, мы хотим только возвратить значение поля и ничего больше. Методы, созданные таким образом, называют getter, и Rust не реализует их автоматически для полей структуры, как это делают некоторые другие языки программирования. Getter-ы полезны, потому что вы можете сделать поле приватным (private), но метод сделать публичным, и таким способом реализовать доступ read-only к такому полю как часть public API для типа. Мы обсудим, что такое public и private, и как обозначить это для поля или метода, в главе 7.

На языках C и C++ существует два разных оператора для вызова методов: вы используете ., если вызываете метод напрямую на объекте, и ->, если вызываете метод на указателе на этот объект, либо сначала вам нужно разыменовать указатель. Другими словами, если object это указатель, то object->something() делает то же самое, что и (*object).something().

В языке Rust нет эквивалента для оператора ->; вместо этого есть фича, которая называется автоссылкой (automatic referencing) и авторазыменованием (automatic dereferencing). Вызов методов это одно из немногих мест, где имеется такое поведение.

Как это работает: когда вы вызываете метод через object.something(), Rust автоматически добавит &, &mut или * к object, чтобы это соответствовало сигнатуре метода. Другими словами следующие две строки делают одно и то же:

p1.distance(&p2);
(&p1).distance(&p2);

Первая строка выглядит понятнее. Это automatic referencing поведение нормально работает, потому что методы ясно имеют получателя — тип self. Имея в наличии получателя и имя метода, Rust может окончательно выяснить, является ли метод считывающим (&self), мутирующим (&mut self), либо потребляющим, т. е. захватывающим владение (self). Тот факт, что Rust делает заимствование неявным для получателей методов, дает большей частью обеспечение эргономичности владения на практике.

Методы с несколькими параметрами. Давайте попрактикуемся в использовании методов, и создадим второй метод на структуре Rectangle. На этот раз мы хотим, чтобы экземпляр Rectangle принял другой экземпляр Rectangle и возвратил true, если второй Rectangle может полностью поместиться в нем (т. е. в первом Rectangle); иначе нужно возвратить false. Такой метод мы могли бы назвать can_hold, и он мог бы использоваться в программе листинга 5-14.

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

println!("Может rect1 уместить в себе rect2? {}", rect1.can_hold(&rect2)); println!("Может rect1 уместить в себе rect3? {}", rect1.can_hold(&rect3)); }

Листинг 5-14. Использование пока что не реализованного метода can_hold.

Ожидаемый вывод должен выглядеть так, поскольку оба размера rect2 меньше размеров rect1, но rect3 шире, чем rect1:

Может rect1 уместить в себе rect2? да
Может rect1 уместить в себе rect3? нет

Поскольку мы хотим определить метод для структуры, то его будем размещать внутри блока impl Rectangle. Имя метода будет can_hold, и он будет принимать в качестве параметра немутируемое заимствование (immutable borrow) другого объекта. Мы можем сказать, какой будет тип у параметра, посмотрев на код, который вызывает метод: rect1.can_hold(&rect2) передает &rect2, что является немутируемым заимствованием rect2, экземпляра Rectangle. Это имеет смысл, потому что нам нужно всего лишь прочитать rect2 (вместо его записи, тогда нам надо было бы организовать мутируемое заимствование), и мы хотим оставить владение над rect2 за вызывающи кодом, чтобы его можно было бы и дальше использовать после вызова метода can_hold. Возвращаемым значением из can_hold будет двоичный тип, и реализация будет проверять, чтобы ширина и высота self была больше, чем у другого Rectangle. Давайте добавим такой метод can_hold в блок impl из листинга 5-13, и получится то, что показано в листинге 5-15:

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

fn can_hold(&self, other: &Rectangle) -> bool { self.width > other.width && self.height > other.height } }

Листинг 5-15. Реализация метода can_hold на типе Rectangle. Этот метод может принять экземпляр другого Rectangle в качестве параметра.

Если мы запустим этот код с функцией main из листинга 5-14, то получим желаемый вывод. Методы могут принимать несколько параметров, которые можно добавлять в сигнатуру функции после параметра self, и эти параметры просто работают аналогично параметрам в функциях.

Связанные функции. Все функции, определенные внутри блока impl, называются связанными (associated functions), потому что они привязаны к типу, имя которого вставлено после impl. Мы можем определить связанные функции, у которых нет self в качестве их первого параметра (и таким образом эти функции не будут методами), потому что им не нужен экземпляр (взятый во владение или заимствованный) типа для своей работы. Мы уже использовали одну такую функцию String::from, которая определена в типе String.

Связанные функции, которые не являются методами, часто используются для конструкторов, которые будут возвращать новый экземпляр структуры. Их часто называют new, однако new не является специальным именем и не встроено в язык Rust, как на языке C++. Например, мы можем предоставить связанную функцию с именем square, которая будет иметь один параметр измерения, используемый и для ширины, и для высоты, что упрощает создание квадратного Rectangle вместо того, чтобы указывать одну и ту же величину дважды для ширины и высоты:

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

Ключевые слова Self в возвращаемом типе и теле функции это псевдонимы (aliases) для типа, который появляется после ключевого слова impl, и в нашем случае это тип Rectangle.

Для вызова связанной функции мы используем синтаксис :: с именем структуры, например let sq = Rectangle::square(3);. Эта функция находится в пространстве имен (namespace) структуры: синтаксис :: используется как для связанных функций, так и для пространств имен, созданных модулями. Мы обсудим модули в главе 7.

Несколько блоков impl. Для каждой структуры можно создавать несколько блоков impl. Например, листинг 5-15 эквивалентен коду в листинге 5-16, где каждый метод находится в своем собственном блоке impl.

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle { fn can_hold(&self, other: &Rectangle) -> bool { self.width > other.width && self.height > other.height } }

Листинг 5-16. Переписанный листинг 5-15 с несколькими блоками impl.

Здесь нет причины разделения этих методов, чтобы они находились в отдельных блоках impl, но это допустимый синтаксис. Мы увидим случай, когда несколько блоков impl окажутся полезными, в главе 10, где мы обсудим generic-типы и трейты (traits).

[Общие выводы]

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

Однако структуры не единственный способ, каким вы можете создавать свои собственные типы: давайте обратимся к Rust-фиче перечисления (enum), чтобы у вас появился еще один инструмент программирования.

[Ссылки]

1. Using Structs to Structure Related Data site:rust-lang.org.
2. Rust: что такое Ownership.
3. Rust: общая концепция программирования.
4. Attributes Rust Reference site:rust-lang.org.

 

Добавить комментарий


Защитный код
Обновить

Top of Page