Структуры
Одним из представителей составных типов в Rust являются структуры.
Структура — именованный набор полей различного типа, который составляет новый тип данных.
Синтаксис объявления структуры следующий:
struct ИмяСтруктуры {
поле_1: Тип1,
поле_2: Тип2,
...
поле_N: ТипN,
}
Заметьте, что после последнего поля структуры можно ставить запятую (так же, как и после последнего аргумента при объявлении функции).
Поля структуры, как и переменные, должны именоваться в соответствии со змеиной нотацией. Имена самих структур должны следовать паскалевской нотации (Pascal case): имя начинается с заглавной буквы, и если в названии присутствует несколько слов, то каждое слово также начинается с заглавной буквы. Например: User, MainAddress, DatabaseConnection.
Пример структуры, которая хранит имя и фамилию человека:
#![allow(unused)]
fn main() {
struct Person {
first_name: String,
last_name: String,
}
}
Синтаксис создания экземпляра структуры:
let переменная = ИмяСтруктуры { поле_1: значение_1, ..., поле_N: значение_N};
Например:
let person = Person {
first_name: String::from("John"),
last_name: String::from("Doe"),
};
Доступ к полю структуры осуществляется при помощи точки: структура.поле.
my_struct.field_1
Пример создания и использования структуры:
struct Person {
first_name: String,
last_name: String,
}
fn get_full_name(p: &Person) -> String {
format!("{} {}", p.first_name, p.last_name)
}
fn main() {
let p = Person {
first_name: "John".to_string(),
last_name: "Doe".to_string()
};
let full_name = get_full_name(&p);
println!("{}", full_name); // "John Doe"
}
Если мы инициализируем поле структуры значением из переменной, чьё имя совпадает с именем этого поля, то вместо имя_поля: имя_переменной можно просто указать переменную.
#![allow(unused)]
fn main() {
let first_name = String::from("John");
let person = Person {
first_name,
last_name: String::from("Doe"),
};
}
Чтобы иметь возможность изменять значения полей у переменной типа структуры, вся переменная должна быть объявлена как мутабельная.
fn main() {
let mut p = Person {
first_name: "John".to_string(),
last_name: "Doe".to_string()
};
p.first_name = "Theodor".to_string();
}
Создание одного экземпляра из другого
Если мы хотим создать объект структуры из другого объекта этой же структуры путем изменения значения некоторых полей, то для такой ситуации имеется специальный синтаксис:
let новый_объект = Структура {
поле1: новое значение 1,
поле2: новое значение 2,
..старый_объект
};
Пример:
struct Person {
first_name: String,
last_name: String,
}
fn main() {
let p1 = Person {
first_name: "John".to_string(),
last_name: "Doe".to_string()
};
let p2 = Person { first_name: "Robert".to_string(), ..p1};
println!("{} {}", p2.first_name, p2.last_name); // Robert Doe
}
Методы
В отличие от традиционных ООП языков, где методы объявляются в теле класса, в Rust методы структуры объявляются отдельно от неё.
Синтаксис:
impl ИмяСтруктуры {
// метод, который НЕ меняет вызывающий объект
fn метод_1(&self, аргумент_1: Тип1, …, аргумент_N: ТипN) -> ТипРезультата {
...
}
// метод, который меняет вызывающий объект
fn метод_2(&mut self, аргумент_1: Тип1, …, аргумент_N: ТипN) -> ТипРезультата {
...
}
// "статический" метод, вызываемый на структуре, а не на объекте структуры
fn метод_3(аргумент_1: Тип1, …, аргумент_N: ТипN) -> ТипРезультата {
...
}
}
Аналогом ключевого слова this из C++ и Java является ключевое слово self, которое необходимо явно передавать в качестве первого аргумента метода.
- Если метод не меняет состояние объекта, на котором он вызван, то self можно передавать по немутабельной ссылке —
&self. - Если же методу необходимо изменять хотя бы одно из полей структуры, то self необходимо передавать по мутабельной ссылке —
&mut self. - В редких случаях, когда метод должен забрать во владение объект, на котором он был вызван, self необходимо передавать по значению —
self.
Пример:
struct Person {
first_name: String,
last_name: String,
age: u32
}
impl Person {
fn new(first: &str, last: &str) -> Person {
Person {
first_name: first.to_string(),
last_name: last.to_string(),
age: 0
}
}
fn change_age(&mut self, new_age: u32) {
self.age = new_age;
}
fn introduce(&self) -> String {
format!("{} {} is {} years old", self.first_name, self.last_name, self.age)
}
}
fn main() {
let mut p = Person::new("John", "Doe");
p.change_age(25);
println!("{}", p.introduce());
}
Кортежные структуры (tuple structs)
В дополнение к традиционным структурам, которые являются коллекцией именованных полей, Rust предлагает так называемые кортежные структуры, где поля идентифицируются не именем, а позицией.
Синтаксис:
struct Имя(Тип1, Тип2, …, ТипN);
Пример:
/// Представляет цвет, закодированный RGB каналами
struct RGB (u8, u8, u8);
impl RGB {
/// Упаковывает все 3 канала в одно 4-байтное число
fn as_u32(&self) -> u32 {
((self.0 as u32) << 16)
+ ((self.1 as u32) << 8)
+ (self.2 as u32)
}
}
fn main() {
let mut color: RGB = RGB(255, 0, 0); // красный цвет
println!("Red channel: {}", color.0); // Red канал: 255
color.1 = 255; // Выставляет зелёный канал в 255
// кортежную структуру можно разложить на составляющие
// так же, как и обычный кортеж
let RGB(r, g, b) = color;
println!("R={r}, G={g}, B={b}"); // R=255, G=255, B=0
println!("As number: {}", color.as_u32());
}
Как видим, кортежная структура — это фактически обычный кортеж, только с осмысленным именем и возможностью добавить к нему дополнительные методы.
Структура-синглтон
В Rust имеется возможность создать структуру, в которой нет полей. Например:
#![allow(unused)]
fn main() {
struct Universe;
}
Очевидно, что для такой структуры возможен лишь один вариант объекта, поэтому такая структура называется синглтоном. Объект синглтон структуры ведёт себя так же, как и объект обычной структуры с полями.
struct Universe;
impl Universe {
fn includes(&self, p: &Planet) -> bool {
true
}
}
struct Planet {
name: String
}
fn main() {
let universe = Universe;
let earth = Planet { name: "Earth".to_string() };
println!("{}", universe.includes(&earth)); // true
}
Практическое применение таких структур станет понятнее после прочтения главы Трэйты.
Лайфтаймы у структур
В главе Лайфтаймы мы узнали, что такое лайфтаймы в целом и как задавать их для функций. Теперь давайте разберёмся с лайфтаймами для структур.
Если у структуры есть поле, в котором хранится ссылка, то для этой ссылки необходимо указать лайфтайм. Это нужно для того, чтобы компилятор мог сопоставить время жизни поля-ссылки и время жизни объекта, ссылка на который хранится в поле структуры.
Рассмотрим пример: у нас будет строка String, которая хранит имя и фамилию, разделённые пробелом, и мы сделаем структуру с двумя полями:
- ссылка на часть строки, где хранится имя
- ссылка на часть строки, где хранится фамилия
#[derive(Debug)]
struct NameComponents<'a> {
first_name: &'a str,
last_name: &'a str,
}
fn main() {
let full_name = "John Doe".to_string();
let space_position = full_name.find(" ").unwrap();
let components = NameComponents {
first_name: &full_name[0..space_position],
last_name: &full_name[space_position + 1 ..],
};
println!("{components:?}");
// NameComponents { first_name: "John", last_name: "Doe" }
}
Благодаря лайфтайму компилятор сможет проконтролировать, что объект структуры NameComponents не “переживёт” строку, на которую ссылаются его поля-ссылки. Например, такой вариант не скомпилируется:
fn main() {
let components;
{
let full_name = "John Doe".to_string();
let space_position = full_name.find(" ").unwrap();
// Error: `full_name` does not live long enough
components = NameComponents {
first_name: &full_name[0..space_position],
last_name: &full_name[space_position + 1 ..],
};
}
println!("{components:?}");
}
Лэйаут в памяти
Warning
Если вы плохо знакомы с архитектурой компьютера и плохо понимаете, как данные располагаются на стеке, то этот раздел, скорее всего, покажется непонятным. В таком случае не стоит заострять на нём внимание, так как эта информация не пригодится при разработке back-end приложений на Rust. Однако в будущем настоятельно рекомендуется разобраться с этой темой.
Теперь давайте разберёмся, как экземпляры структур располагаются в оперативной памяти.
Note
Результаты нижеприведённых примеров получены на x86_64 процессоре и версии Rust 1.92
Рассмотрим пример: воспользуемся стандартной функцией size_of, которая возвращает размер типа в байтах.
struct MyStruct {
a: i64,
b: i32,
}
fn main() {
println!("Size = {}", std::mem::size_of::<MyStruct>()); // Size = 16
}
Как так получается, что структура, состоящая из полей, чей размер соответственно 8 и 4 байта, занимает 16 байт?
Дело в том, что компилятор применяет так называемое “выравнивание”, т.е. располагает поля так, чтобы процессор мог адресовать их за одну операцию считывания или записи.
Note
Процессоры семейства x86_64 имеют регистры размером 8 байт. При операции чтения процессор может адресовать только блоки памяти, чей адрес кратен 8. Например, считать в регистр можно блоки ячеек по адресам 0-7, или 8-15, или 16-23 и т.д. Процессор не может запросить ячейки в диапазоне 4-11 или, тем более, 4-7.
Поэтому когда необходимо вычитать значение, чьё начало расположено по адресу, не кратному 8 (например, 4-11), то процессору приходится считывать куски из смежных блоков (т.е. 0-7 и 8-16), далее битовыми операциями “отрезать” от них нужную часть и “склеивать” воедино. Все эти операции занимают лишние машинные такты. Поэтому целесообразнее выровнять значения в памяти так, чтобы значения можно было считывать за одну операцию. Пусть платой за это и станет перерасход памяти. Получившиеся “пробелы” в памяти называют паддингами (padding).
Давайте посмотрим, по каким адресам располагаются поля a и b. Для этого мы получим указатель на каждое из полей, а потом воспользуемся методом addr(), который возвращает числовое значение адреса указателя.
struct MyStruct {
a: i32,
b: i64,
}
fn main() {
println!("Size = {}", std::mem::size_of::<MyStruct>()); // Size = 16
let s = MyStruct { a: 1, b: 2 };
println!("a: {}", ((&s.a) as *const i32).addr()); // a: 140731421349072
println!("b: {}", ((&s.b) as *const i64).addr()); // b: 140731421349064
}
Оба адреса 140731421349072 и 140731421349064 кратны 8.
Note
Обычно для распечатки адреса используется форматирующая последовательность
{:p}макросаprintln!, но мы просто хотели явно продемонстрировать, каким образом получается адрес переменной.
Теперь давайте посмотрим как располагаются в памяти массивы структур:
struct MyStruct {
a: i32,
b: i64,
}
fn main() {
let arr = [
MyStruct { a: 1, b: 2 },
MyStruct { a: 3, b: 4 },
MyStruct { a: 5, b: 6 }
];
println!("arr[0].a: {:p}", &arr[0].a); // arr[0].a: 0x7ffdc124d970
println!("arr[0].b: {:p}", &arr[0].b); // arr[0].b: 0x7ffdc124d968
println!("arr[1].a: {:p}", &arr[1].a); // arr[1].a: 0x7ffdc124d980
println!("arr[1].b: {:p}", &arr[1].b); // arr[1].b: 0x7ffdc124d978
println!("arr[2].a: {:p}", &arr[2].a); // arr[2].a: 0x7ffdc124d990
println!("arr[2].b: {:p}", &arr[2].b); // arr[2].b: 0x7ffdc124d988
}
В памяти это выглядит так (здесь мы используем представление адресов в шестнадцатеричном формате):
Здесь хорошо видны как выравнивание, так и паддинги.
Также мы видим, что компилятор поменял местами поля a и b. Да, Rust не гарантирует, что поля структуры в памяти будут иметь такой же порядок, как и в коде объявления структуры.
Если необходимо, чтобы компилятор располагал поля в памяти в том же порядке, что и в объявлении структуры (обычно это нужно при вызове функций из библиотек, написанных на других языках), то структуру следует пометить аннотацией #[repr(C)].
#![allow(unused)]
fn main() {
#[repr(C)]
struct MyStruct {
a: i32,
b: i64,
}
}
Эта аннотация указывает, что структура должна иметь представление, совместимое с представлением структур в языке C, где порядок полей структуры в памяти должен соответствовать порядку полей в коде объявления структуры.