Есть ли ООП в Rust?
Многие программисты уже умеют программировать на объектно-ориентированных языках. Rust не является классическим объектно-ориентированным языком, но основные инструменты ООП можно применять и в нём.
В этой статье мы рассмотрим, как программировать на Rust в ООП-стиле. Мы будем делать это на примере: построим иерархию классов в учебной задаче.
Наша задача — это работа с геометрическими фигурами. Мы будем выводить их на экран в текстовом виде и вычислять их площадь. Наш набор фигур — прямоугольник, квадрат, эллипс, круг.
Содержание
- Содержание
- Что такое ООП
- Самая наивная реализация
- Проверка данных при создании экземпляров
- Сокрытие частных полей
- Обобщение через перечисление
- Простое наследование
- Обобщение кода через типажи
- Печать фигур
- Выносим обобщённый код в функцию
- Убираем ручное наследование
- Добавляем эллипсы
- Как выбирать способ «наследования»
- Заключение
Что такое ООП
Теперь определимся с тем, что значит «программировать в ООП-стиле». Всё ООП в одной статье охватить невозможно, поэтому выделим несколько основных черт, которые будем пытаться реализовать.
Абстракция данных
Объединение данных одной сущности в одном объекте. Это возможность представить сущность сразу совокупностью её свойств, а не отдельными переменными.
Структуры в Си, записи в Haskell, объекты в Java — все они реализуют абстракцию данных.
В Rust абстракция данных представлена структурами.
Инкапсуляция
Объединение данных и методов работы с этими данными. Это возможность вызвать у экземпляра объекта метод, который делает что-то с экземпляром. Это значит, что вызов метода автоматически передаёт объект в качестве контекста в метод — часто в виде первого аргумента. Это также значит, что объект или класс предоставляет своё пространство имён, в котором находятся его методы.
Инкапсуляция есть в C++, Java, и многих других объектно-ориентированных языках.
В Rust есть инкапсуляция, работает через методы, определённые на структурах.
Сокрытие
Сокрытие деталей реализации функциональности от пользователей. Это значит, что есть как минимум общие и частные поля и методы. Общие методы доступны пользователям объекта, а частные доступны только изнутри реализации.
Сокрытие есть в C++, Java и многих других объектно-ориентированных языках.
В Rust сокрытие также есть — существуют общие и частные поля структур и методы. Частные элементы доступны в реализации функциональности, но недоступны снаружи.
Наследование
Переиспользование методов классов-родителей, то есть переиспользование более общих реализаций в более частных. Это значит, что метод родителя будет вызван в отсутствие реализации метода для потомка.
Наследование есть в C++, Java и многих других объектно-ориентированных языках.
В Rust нет классического наследования, но есть возможность изобразить его с помощью типажей.
Полиморфизм подтипов
Возможность работать с объектами-потомками так же, как с объектами-родителями. Т.е. если все потомки наследуются от одного класса, мы можем обработать их все как экземпляры этого класса.
Полиморфизм подтипов есть в C++, Java и многих других объектно-ориентированных языках.
В Rust есть полиморфизм подтипов и реализуется он через типажи и типажи-объекты.
Самая наивная реализация
Давайте начнём с реализации структур прямоугольника и квадрата.
1 2 3 4 5 6 7 8 | struct Rectangle { width: f64, length: f64, } struct Square { side: f64, } |
Это определения структур. Можно сказать, что это подобие классов. С этими определениями мы сможем создавать экземпляры прямоугольников и квадратов.
Ещё нам нужно определить методы подсчёта площади для обеих структур.
1 2 3 4 5 6 7 8 9 10 11 12 13 | impl Rectangle { fn area(&self) -> f64 { self.width * self.length } } impl Square { fn area(&self) -> f64 { self.side * self.side } } |
Этим всем можно пользоваться так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | fn main() { let rect1 = Rectangle { width: 3., length: 5. }; let rect2 = Rectangle { width: 4., length: 6. }; let sq1 = Square { side: 8. }; let sq2 = Square { side: 4. }; let rects = [&rect1, &rect2]; let squares = [&sq1, &sq2]; for r in rects.iter() { println!("Площадь равна {}", r.area()); } for s in squares.iter() { println!("Площадь равна {}", s.area()); } } |
Код выше выведет такие сообщения:
1 2 3 4 | Площадь равна 15 Площадь равна 24 Площадь равна 64 Площадь равна 16 |
В строках 2, 3 и 5, 6 мы создаём экземпляры прямоугольников и квадратов, используя синтаксис литералов структур.
В строках 8, 9 мы складываем прямоугольники и квадраты в соответствующие массивы, а затем обходим их, вычисляем и печатаем площадь.
Сейчас мы не печатаем саму фигуру. Чтобы печатать её, нужно реализовать ещё один метод. Этот метод должен делать форматированный вывод. Идиоматично это делается с помощью типажа Display. Однако мы ещё не говорили про типажи, поэтому давайте отложим этот вопрос.
Оценка
- Хорошо
-
Можно написать не глядя
Такой код напишет любой новичок в Rust.
-
- Плохо
-
Нет обобщённой обработки
Мы не можем обобщённо обработать прямоугольники и квадраты, хотя все они являются прямоугольниками.
-
Нет наследования кода
Даже если реализация вычисления площади одинакова для предка и потомка, её необходимо написать дважды.
-
Новый тип — новый код
Появление нового типа требует написания нового
impl
-блока и методаarea
. -
Нет защиты деталей реализации
Мы видим все поля структур и можем произвольно их менять.
-
Нет проверки данных при создании экземпляров
Мы создаём экземпляры структур, напрямую задавая значения полей. Мы можем дать им неправильные значения.
-
Давайте решать эти проблемы. Начнём с самой простой — с проверки данных при создании экземпляров.
Проверка данных при создании экземпляров
Проверку значений полей при создании экземпляров можно реализовать с помощью метода-конструктора.
В Rust конструкторы не отличаются от других методов синтаксически. Однако обычно
простейший метод-конструктор называется new
.
Более сложные конструкторы реализуются с помощью паттерна Builder и чаще всего
называются вроде with_proxy_config
(конструктор HTTP-клиента, принимающий
конфигурацию прокси).
Давайте реализуем наши конструкторы.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | impl Rectangle { fn new(width: f64, length: f64) -> Option<Rectangle> { if width > 0. && length > 0. { Some( Rectangle { length, width } ) } else { None } } // ... } impl Square { fn new(side: f64) -> Option<Square> { if side > 0. { Some( Square { side } ) } else { None } } // ... } |
Обратите внимание, что наши конструкторы возвращают Option<Rectangle>
и
Option<Square>
. Это сделано чтобы не возвращать экземпляр в случае
неправильного вызова конструктора. В Rust нет исключений. Мы не создаём квадраты
с отрицательными сторонами. Поэтому мы возвращаем перечисление — если создание
удалось, у нас будет Some<T>
; если не удалось — None
. Строго говоря, это не
совсем идиоматичная обработка ошибок. Лучше было бы возвращать Result<T, E>
,
но это уже совсем другая тема.
На стороне вызывающего нам нужно будет обработать возможную ошибку. Сейчас мы не
будем её обрабатывать, и просто вызовем .unwrap()
— это даёт T
из Some(T)
,
или паникует если Option
оказался None
. Паника — это раскрутка стека с
вызовом деструкторов и завершение программы с опциональной распечаткой обратной
трассировки вызовов.
1 2 3 4 5 6 7 8 9 10 | fn main() { let rect1 = Rectangle::new(3., 5.).unwrap(); let rect2 = Rectangle::new(4., 6.).unwrap();; // этот вызов приводит к панике let rect3 = Rectangle::new(-4., 6.).unwrap();; let sq1 = Square::new(8.).unwrap(); let sq2 = Square::new(4.).unwrap(); // ... |
Мы улучшили инкапсуляцию, однако напрямую создать структуру с помощью синтаксиса литералов всё ещё можно. Давайте теперь разберёмся с этим.
Сокрытие частных полей
Разделение частных и общих полей и методов работает на уровне модулей. Внутри модуля все функции, методы и поля структур доступны без ограничений — независимо от того, являются они частными или общими. Вне модуля частные элементы в общем случае не доступны.
Модули могут вкладываться друг в друга. В корневом модуле может быть модуль a
,
в нём модуль b
, а в нём — модуль c
. При этом частные элементы вышестоящих
модулей доступны во вложенных модулях. Так, частные элементы корневого модуля
доступны во всех модулях — корневом, a
, b
и c
. Частные элементы модуля a
доступны в модулях a
, b
и c
, но не доступны в корневом модуле. Частные
элементы b
видимы в b
и c
. И, наконец, частные элементы c
доступны
только в c
.
Зная это, мы можем скрыть нужные нам элементы, являющиеся деталями реализации.
В данном случае это поля width
и length
прямоугольника и side
квадрата.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | mod figures { pub struct Rectangle { width: f64, length: f64, } pub struct Square { side: f64, } impl Rectangle { pub fn new(width: f64, length: f64) -> Option<Rectangle> { if width > 0. && length > 0. { Some( Rectangle { length, width } ) } else { None } } pub fn area(&self) -> f64 { self.width * self.length } } impl Square { pub fn new(side: f64) -> Option<Square> { if side > 0. { Some( Square { side } ) } else { None } } pub fn area(&self) -> f64 { self.side * self.side } } } fn main() { let rect1 = figures::Rectangle::new(3., 5.).unwrap(); let rect2 = figures::Rectangle::new(4., 6.).unwrap();; // error: field `width` is private let rect3 = figures::Rectangle { width: 3., length: 5. }; let sq1 = figures::Square::new(8.).unwrap(); let sq2 = figures::Square::new(4.).unwrap(); let rects = [&rect1, &rect2]; let squares = [&sq1, &sq2]; for r in rects.iter() { println!("Площадь равна {}", r.area()); } for s in squares.iter() { println!("Площадь равна {}", s.area()); } } |
Теперь создание структур с помощью синтаксиса литералов невозможно. Этого мы и добивались.
Возникло одно неудобство — к структурам теперь надо обращаться в модуль
figures
. Это легко решается с помощью импорта имён:
1 | use figures::{Rectangle, Square}; |
Оценка
Теперь наша реализация выглядит так (старые пункты выделены курсивом):
- Хорошо
-
Достаточно просто
Реализация методов очень похожа на другие ОО-языки.
-
Есть защита деталей реализации
Частные поля не видны пользователям.
-
Есть проверка данных при создании экземпляров
Конструкторы проверяют значения полей экземпляров при их создании.
-
- Плохо
- Нет обобщённой обработки
- Нет наследования кода
- Новый тип — новый код
Дальше мы будем решать проблему обобщённой обработки фигур.
Обобщение через перечисление
Самый простой способ обобщить фигуры — использовать перечисление:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | pub enum Figure { Rect(Rectangle), Sq(Square), } impl Figure { pub fn area(&self) -> f64 { match self { &Figure::Rect(ref r) => r.area(), &Figure::Sq(ref s) => s.area(), } } } |
Теперь мы можем хранить их в одном массиве и обрабатывать единообразно:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | fn main() { let rect1 = Figure::Rect(Rectangle::new(3., 5.).unwrap()); let rect2 = Figure::Rect(Rectangle::new(4., 6.).unwrap()); let sq1 = Figure::Sq(Square::new(8.).unwrap()); let sq2 = Figure::Sq(Square::new(4.).unwrap()); let figures = [&rect1, &rect2, &sq1, &sq2]; for f in figures.iter() { println!("Площадь равна {}", f.area()); } } |
Мы могли бы даже избавиться от ручного оборачивания фигур в соответствующие варианты перечисления, но не будем этого делать, т. к. у этого способа обобщения кода много других недостатков.
Оценка
- Хорошо
- Достаточно просто
- Есть защита деталей реализации
- Есть проверка данных при создании экземпляров
-
Есть обобщённая обработка
Теперь все фигуры можно обойти в цикле и сделать с ними что-то, не зная их типа.
- Плохо
- Нет наследования кода
- Новый тип — новый код
-
Размер объектов-перечислений максимален
Даже если в перечислении хранится квадрат, места будет зарезервировано столько, сколько нужно под прямоугольник (под 2
f64
вместо одного).
С размером объектов перечислений мы в принципе ничего не сделаем, но давайте попробуем решить проблему наследования.
Простое наследование
Сделаем наследование так, как его обычно делают в Си: положим объект-предок в первое поле объекта-потомка:
1 2 3 | pub struct Square { rectangle: Rectangle, } |
Это потребует небольших изменений в методах квадрата:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | impl Square { pub fn new(side: f64) -> Option<Square> { if side > 0. { Some( Square { rectangle: Rectangle { length: side, width: side } } ) } else { None } } pub fn area(&self) -> f64 { self.rectangle.area() } } |
Как видите, мы вручную позвали метод предка. Тем не менее, это работает и не требует изменений в коде, пользующимся этими структурами.
Оценка
- Хорошо
- Достаточно просто
- Есть защита деталей реализации
- Есть проверка данных при создании экземпляров
- Есть обобщённая обработка
- Есть наследование кода
- Плохо
- Новый тип — новый код
- Размер объектов-перечислений максимален
-
Размер квадрата равен размеру прямоугольника
Теперь у нас сама структура квадрата хранит в себе прямоугольник. Это не необходимо и является деоптимизацией.
С этими недостатками уже ничего не сделаешь, и необходимость писать новый код при появлении нового типа выглядит достаточно серьёзной проблемой, чтобы попробовать сделать наследование по-другому. Для этого нам потребуются типажи.
Обобщение кода через типажи
Типажи — это способ обобщать код в Rust. Определение типажа включает в себя сигнатуры необходимых методов:
1 2 3 | pub trait Area { fn area(&self) -> f64; } |
Если реализовать этот типаж для структуры, то у структуры появится объявленный в типаже метод:
1 2 3 4 5 6 7 8 9 10 11 12 13 | impl Area for Rectangle { fn area(&self) -> f64 { self.width * self.length } } impl Area for Square { fn area(&self) -> f64 { self.rectangle.area() } } |
Из impl
-блоков самих структур (impl Rectangle
и impl Square
) мы убираем
метод area
.
Но мы уже могли реализовать метод, чем интересны типажи?
Они интересны тем, что структуры разных типов, реализующих один типаж, можно привести к одному типу. Ссылку на структуру, реализующую типаж, можно преобразовать в так называемый «типаж-объект». Это объект, являющийся толстым указателем: он хранит ссылку на данные (структуру) и на методы работы с этой структурой (таблицу виртуальных методов, vtable). При этом конкретный тип структуры стирается — всё, что известно о типаже-объекте — это то, что он реализует определённый типаж.
Это значит, что мы можем сделать следующее:
1 2 3 4 5 6 7 8 9 10 11 12 13 | fn main() { let rect1 = Rectangle::new(3., 5.).unwrap(); let rect2 = Rectangle::new(4., 6.).unwrap(); let sq1 = Square::new(8.).unwrap(); let sq2 = Square::new(4.).unwrap(); let figures_with_area: [&Area; 4] = [&rect1, &rect2, &sq1, &sq2]; for f in figures_with_area.iter() { println!("Площадь равна {}", f.area()); } } |
Здесь у всех структур есть площадь — и мы кладём в figures_with_area
структуры, реализующие типаж Area
. Благодаря тому, что мы указали тип массива,
компилятор понимает, что мы хотим преобразовать ссылки на объекты в
объекты-типажи. Это также можно было бы сделать так:
1 | let figures_with_area = [(&rect1) as &Area, &rect2, &sq1, &sq2]; |
Мы получили обобщённую обработку без использования перечислений и без увеличения размера объектов. Наши накладные расходы постоянны — это два указателя на типаж-объект.
Обратите внимание, что, в отличие от C++, таблица виртуальных методов не находится внутри объекта, который реализует виртуальные методы. Здесь таблица появляется «на стороне вызывающего» — если он хочет обобщённо использовать структуру, vtable появляется там, где мы хотим стереть типы конкретных структур. В нашем случае это происходит при складывании разных структур в один массив.
Оценка
- Хорошо
- Достаточно просто
- Есть защита деталей реализации
- Есть проверка данных при создании экземпляров
- Есть обобщённая обработка
- Есть наследование кода
- Плохо
- Новый тип — новый код
- Размер квадрата равен размеру прямоугольника
У нас остался единственный недостаток — наследование реализуется вручную и требует ручного проброса метода. Также нам нужно опять оптимально хранить квадрат — только его сторону. И ещё одна изначально нереализованная возможность — мы хотели печатать саму фигуру, но не стали этого делать, т. к. это требовало знаний о типажах. Теперь мы можем реализовать печать.
Печать фигур
Типаж для печати объектов в терминал определён в стандартной библиотеке Rust следующим образом:
1 2 3 | pub trait Display { fn fmt(&self, f: &mut Formatter) -> Result<(), Error>; } |
Не будем вдаваться в подробности — достаточно знать, что мы можем сделать
форматированный вывод с помощью Formatter
следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 | use std::fmt; impl fmt::Display for Rectangle { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "прямоугольник({}, {})", self.width, self.length) } } impl fmt::Display for Square { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "квадрат({}, {})", self.rectangle.width, self.rectangle.length) } } |
Это позволяет обобщённо печатать фигуры:
1 2 3 4 5 6 7 8 9 10 11 12 13 | fn main() { let rect1 = Rectangle::new(3., 5.).unwrap(); let rect2 = Rectangle::new(4., 6.).unwrap(); let sq1 = Square::new(8.).unwrap(); let sq2 = Square::new(4.).unwrap(); let figures_with_display: [&Display; 4] = [&rect1, &rect2, &sq1, &sq2]; for f in figures_with_display.iter() { println!("Фигура: {}", f); } } |
Этот код выводит следующий текст:
1 2 3 4 | Фигура: прямоугольник(3, 5) Фигура: прямоугольник(4, 6) Фигура: квадрат(8, 8) Фигура: квадрат(4, 4) |
Всё в порядке. Давайте теперь вынесем обобщённый код в функцию.
Выносим обобщённый код в функцию
Начнём с функции печати только площади:
1 2 3 4 5 6 7 8 9 10 11 12 | fn print_areas(figures: &[&Area]) { for f in figures.iter() { println!("Площадь равна {}", f.area()); } } fn main() { /// ... print_areas(&[&rect1, &rect2, &sq1, &sq2]); } |
Этот код работает. Функция принимает срез массива типажей-объектов. Срез — это ссылка на массив, которая «знает» его длину. Т.е. мы можем обойти срез, не проверяя выход за границу.
Теперь попробуем потребовать, чтобы наши типажи-объекты реализовывали и Area
,
и Display
:
1 2 3 4 5 6 | fn print_figures_and_areas(figures: &[&(Area + Display)]) { for f in figures.iter() { println!("Площадь равна {}", f.area()); } } |
Это не работает:
1 2 3 4 5 | error[E0225]: only Send/Sync traits can be used as additional traits in a trait object --> src/main.rs:70:48 | 70 | fn print_figures_and_areas(figures: &[&(Area + Display)]) | ^^^^^^^ non-Send/Sync additional trait |
По какой-то причине только Send
и Sync
могут быть использованы как
дополнительные типажи типажа-объекта. Т.е. мы не можем наивно объединить методы
двух типажей в одной таблице виртуальных методов.
Мы можем это обойти. Попробуем простой способ: передадим два среза. В одном
будут типажи-объекты Display
, а в другом — Area
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | fn print_figures_and_areas( figures_with_area: &[&Area], figures_with_display: &[&Display]) { for (f_a, f_d) in figures_with_area.iter().zip(figures_with_display.iter()) { println!("Площадь {} равна {}", f_d, f_a.area()); } } fn main() { // ... print_figures_and_areas( &[&rect1, &rect2, &sq1, &sq2], &[&rect1, &rect2, &sq1, &sq2]); } |
Это работает.
Это ещё раз показывает «внешнее» создание vtable — если бы таблица находилась в самой структуре, нам бы не пришлось делать такие трюки.
Давайте теперь избавимся от странной пары срезов. Мы можем сделать это, введя другой типаж, который требует реализации других необходимых типажей:
1 | pub trait Figure: Area + fmt::Display { } |
и реализовав его для наших фигур:
1 2 3 | impl Figure for Rectangle { } impl Figure for Square { } |
Типаж Figure
является типажом-маркером: у него нет методов. Мы просто говорим
компилятору, что структуры, реализующие и Area
, и Display
, могут реализовать
Figure
.
Реализация Figure
пустая — достаточно сказать, что типаж реализуется, т. к.
нужные для него методы уже реализованы в наших структурах.
Теперь метод печати площади может нормально обобщённо обрабатывать фигуры:
1 2 3 4 5 6 | fn print_figures_and_areas(figures: &[&Figure]) { for f in figures.iter() { println!("Площадь {} равна {}", f, f.area()); } } |
Вот что он напечатает:
1 2 3 4 | Площадь прямоугольник(3, 5) равна 15 Площадь прямоугольник(4, 6) равна 24 Площадь квадрат(8, 8) равна 64 Площадь квадрат(4, 4) равна 16 |
Теперь у нас есть почти всё. Давайте попробуем избавиться от необходимости писать новый код при появлении нового типа — делать проброс метода.
Убираем ручное наследование
Можно заметить, что площадь для прямоугольника и квадрата вычисляется одинаково — длина умножается на ширину. Но в идеале мы не хотим хранить в квадрате прямоугольник. Это увеличивает его размер в 2 раза. Стало быть, нужен способ сказать, что квадрат — это тоже прямоугольник с длиной и шириной.
Для этого введём типаж:
1 2 3 4 | pub trait Rect { fn width(&self) -> f64; fn length(&self) -> f64; } |
Это типаж прямоугольников. Любая фигура, являющаяся прямоугольником, имеет длину и ширину.
Теперь мы можем определить площадь прямоугольников следующим образом:
1 2 3 4 5 6 | pub trait AreaRect: Rect { fn area(&self) -> f64 { self.width() * self.length() } } |
Мы требуем, чтобы структура уже была прямоугольником (реализовывала Rect
),
чтобы мы могли посчитать её площадь. А дальше реализуем вычисление площади сразу
в типаже — это реализация по умолчанию. Она будет использоваться для всех
структур, которые реализуют этот типаж, но не предоставляют свою реализацию
метода area()
.
Благодаря этому типажу мы можем оптимизировать структуру квадрата и хранить только сторону:
1 2 3 | pub struct Square { side: f64, } |
Теперь нам не надо реализовывать метод вычисления площади. Нужно реализовать методы получения длины и ширины и сказать, что площадь реализуется по умолчанию:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | impl Rect for Rectangle { fn length(&self) -> f64 { self.length } fn width(&self) -> f64 { self.width } } impl AreaRect for Rectangle { } impl Rect for Square { fn length(&self) -> f64 { self.side } fn width(&self) -> f64 { self.side } } impl AreaRect for Square { } |
На стороне пользователя этих структур изменений по-прежнему нет.
Выделив общие черты фигур в типаж прямоугольников, мы получили возможность обобщённо обработать все прямоугольники.
Оценка
- Хорошо
-
Относительно просто
Это не классическое ООП, но здесь нет никаких подводных камней — всё явно и понятно.
- Есть защита деталей реализации
- Есть проверка данных при создании экземпляров
- Есть обобщённая обработка
- Есть наследование кода
-
Размер квадрата оптимален
Квадрат хранит только сторону, как и должен.
-
Новый тип не требует нового кода
При появлении нового типа-наследника прямоугольника, мы реализуем типаж прямоугольника и получаем все методы прямоугольника автоматически.
-
- Плохо
- Нет
Казалось бы, всё прекрасно и всё получилось. Однако есть один момент, который мы не учитывали. Давайте введём ещё одну ветку иерархии фигур — эллипсы — и посмотрим, работает ли наша реализация в таком случае.
Добавляем эллипсы
Структуры эллиптических фигур выглядят так:
1 2 3 4 5 6 7 8 | pub struct Ellipse { a: f64, b: f64, } pub struct Circle { radius: f64, } |
Display
и new
реализовать для них не составит труда:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | impl fmt::Display for Ellipse { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "эллипс({}, {})", self.a, self.b) } } impl fmt::Display for Circle { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "круг({})", self.radius) } } impl Ellipse { pub fn new(a: f64, b: f64) -> Option<Ellipse> { if a > 0. && b > 0. { Some( Ellipse { a, b } ) } else { None } } } impl Circle { pub fn new(radius: f64) -> Option<Circle> { if radius > 0. { Some( Circle { radius } ) } else { None } } } |
Но что делать с площадью? В данный момент наши фигуры жёстко описаны как нечто, у чего площадь можно посчитать как у прямоугольника:
1 2 3 4 5 6 7 8 | pub trait Figure: AreaRect + fmt::Display { } pub trait AreaRect: Rect { fn area(&self) -> f64 { self.width() * self.length() } } |
Очевидно, мы не сможем реализовать AreaRect
для эллипсов. И мы не можем
сказать, что типаж Figure
требует реализации AreaRect
или гипотетического
AreaElliptic
— мы можем потребовать реализации только их обоих одновременно. И
это не будет иметь смысла.
Поэтому нам придётся вернуться к типажу Area
и реализовать его напрямую для
всех фигур:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | pub trait Area { fn area(&self) -> f64; } pub trait Figure: Area + fmt::Display { } impl Figure for Rectangle { } impl Figure for Square { } impl Figure for Ellipse { } impl Figure for Circle { } impl Area for Rectangle { fn area(&self) -> f64 { self.length * self.width } } impl Area for Square { fn area(&self) -> f64 { self.side * self.side } } impl Area for Ellipse { fn area(&self) -> f64 { ::std::f64::consts::PI * self.a * self.b } } impl Area for Circle { fn area(&self) -> f64 { ::std::f64::consts::PI * self.radius * self.radius } } |
Это будет работать как надо:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | fn main() { let rect1 = Rectangle::new(3., 5.).unwrap(); let rect2 = Rectangle::new(4., 6.).unwrap(); let sq1 = Square::new(8.).unwrap(); let sq2 = Square::new(4.).unwrap(); let ellipse1 = Ellipse::new(1., 2.).unwrap(); let ellipse2 = Ellipse::new(2., 4.).unwrap(); let circle1 = Circle::new(1.).unwrap(); let circle2 = Circle::new(2.).unwrap(); print_figures_and_areas( &[&rect1, &rect2, &sq1, &sq2, &ellipse1, &ellipse2, &circle1, &circle2]); } |
1 2 3 4 5 6 7 8 | Площадь прямоугольник(3, 5) равна 15 Площадь прямоугольник(4, 6) равна 24 Площадь квадрат(8, 8) равна 64 Площадь квадрат(4, 4) равна 16 Площадь эллипс(1, 2) равна 6.283185307179586 Площадь эллипс(2, 4) равна 25.132741228718345 Площадь круг(1) равна 3.141592653589793 Площадь круг(2) равна 12.566370614359172 |
Оценка
- Хорошо
- Относительно просто
- Есть защита деталей реализации
- Есть проверка данных при создании экземпляров
- Есть обобщённая обработка
- Есть наследование кода
- Размер квадрата оптимален
- Плохо
- Новый тип — новый код
Нам снова потребуется реализовывать типажи для каждого типа вручную, но здесь нет ограничений на вид иерархии объектов.
Как выбирать способ «наследования»
Отметим ещё раз компромисс:
- Если объекты находятся в одной ветви иерархии, можно сделать типаж
A
, определяющий общие черты этих объектов. Тогда можно сделать типажB
с реализацией по умолчанию, который будет определять поведение всех этих объектов автоматически с помощью методов типажаA
. - Если объекты разнородны и находятся в разных ветвях иерархии, нельзя сделать
такой типаж (
A
). Нужно реализовывать типажB
для каждой структуры напрямую.
В нашем случае A
— это Rect
, а B
— AreaRect
.
Заключение
Мы показали, что многие знакомые по ООП инструменты работы с кодом есть и в Rust. Некоторые очень похожи на понятия из других распространённых языков — структуры, модули, частные и общие элементы; другие реализуются особенным образом и требуют внимания — например, наследование.
Надеемся, эта статья проясняет способы использования ООП-инструментов Rust.
Спасибо за внимание!
Эта статья — улучшенная версия доклада «Есть ли ООП в Rust?».