Существует множество мнений о возможности применения подходов Объектно-Ориентированного Программирования (ООП) и паттернов в Rust. Кто-то считает, что полноценного ООП в Rust нет и быть не может. Другие разработчики, наоборот, высказывают мнение, что элементы языка позволяют использовать приёмы из ООП именно так, как их видели создатели этой парадигмы.

В данной статье я постараюсь продемонстрировать, какие идеи и принципы из ООП позволяет реализовать Rust, и как это работает на простых примерах.

Определение

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

Object-oriented programming (OOP) is a programming paradigm based on the concept of "objects", which can contain data and code: data in the form of fields (often known as attributes or properties), and code, in the form of procedures (often known as methods).

В Rust для описания объектов используются структуры. Они позволяют упаковать в объект нужные ему данные (поля) и наделить объект необходимым функционалом (методами). Отлично! Под базовое определение попали. Идём дальше.

Принципы

Давайте рассмотрим каждый принцип и подумаем, какие возможности предоставляет Rust для их реализации:

  • Инкапсуляция — сокрытие внутренней реализации объекта от внешнего пользователя. В Rust эта идея реализуется с помощью приватных полей и методов структур, используя механизм модулей. Если поле или метод в структуре не помечен как публичный, то для любого внешнего модуля это поле является скрытым и не может быть использовано. Более того, чтобы саму структуру было видно извне её модуля, её тоже нужно помечать, как публичную. Пример:

mod aaa {
    fn foo(inner: bbb::Inner) {
        // Есть доступ к публичному полю.
        let a = inner.public;
        
        // Ошибка компиляции: попытка обращения к приватному полю.
        let b = inner.private;
        
        // Ошибка компиляции: попытка использования приватной структуры.
        let c = bbb::Private {};
    }

    mod bbb {
        pub struct Inner {
            private: i32,
            pub public: i32,
        }

        struct Private {}
    }
}
  • Наследование описывает отношение "является" между двумя объектами. Например, "собака является млекопитающим" или "круг является фигурой". При этом, дочерний объект может быть использован в любом контексте, в котором ожидается родительский объект. Для этого необходимо, чтобы функционал базового объекта присутствовал, также, и в дочернем. Тут в Rust начинают появляться отличия от классического подхода к реализации данной идеи — через классы и интерфейсы. Во-первых, в Rust отсутствует наследование структур, а, следовательно, и наследование данных. Вместо этого в языке есть механизм для описания функционала в отрыве от конкретной реализации. Этот механизм называется трейты. Их могут наследовать как структуры, так и другие трейты. Пример:

trait Shape {
    // У любой фрормы можно посчитать площадь.
    fn area(&self) -> f32;
}

trait HasAngles: Shape {
    // У любой фигуры с углами можно посчитать количество углов.
    fn angles_count(&self) -> i32;
}

struct Rectangle {
    x: f32,
    y: f32,
}

// Прямоугольник является формой.
impl Shape for Rectangle {
    fn area(&self) -> f32 {
        self.x * self.y
    }
}

// Прямоугольник является фигурой с углами.
impl HasAngles for Rectangle {
    fn angles_count(&self) -> i32 {
        4
    }
}

struct Circle {
    r: f32,
}

// Круг является формой
impl Shape for Circle {
    fn area(&self) -> f32 {
        self.r.powi(2) * PI
    }
}
  • Следующий принцип используется как раз для того, чтобы дать возможность использовать в данном контексте любой тип, который является наследником заданного родителя. Этот принцип называют полиморфизмом. Rust поддерживает сразу два вида полиморфизма: статический и динамический. Оба подхода решают одну проблему, но каждый имеет свои особенности.

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

    • Динамический полиморфизм работает посредством динамической диспетчеризации. При этом мы не знаем конкретного типа объекта и для получения адреса его методов в памяти используем дополнительную информацию — таблицу функций. Её содержимое зависит от того, какой конкретный тип скрывается за абстрактным указателем. Такой подход не раздувает исполняемый файл и позволяет подменять реализацию в процессе выполнения программы. Но при этом мы жертвуем частью производительности — для вызова метода нам придётся сначала прочитать его адрес из памяти, что значительно затрудняет оптимизацию программы на этапе компиляции.

Пример статического полиморфизма:

// Принимаем что угодно, реализующее трейт Shape.
fn areas_sum(shape1: impl Shape, shape2: impl Shape) -> f32 {
    shape1.area() + shape2.area()
}

fn foo(rectangle: Rectangle, circle: Circle) {
    // Можем передать две разные фигуры.
    let sum = areas_sum(rectangle, circle);
}

Пример динамического полиморфизма:

// Принимаем что угодно, реализующее трейт Shape.
// В этот раз принимаем не сами объекты, а ссылки на них,
// так как не зная конкретный тип объекта, мы не знаем и его размер,
// а следовательно, не сможем выделить для него место на стеке.
fn areas_sum(shape1: &dyn Shape, shape2: &dyn Shape) -> f32 {
    shape1.area() + shape2.area()
}

fn foo(rectangle: Rectangle, circle: Circle) {
    // Можем передать ссылки на две разные фигуры.
    let sum = areas_sum(&rectangle, &circle);
}
  • Последний принцип не всегда указывают, так как он, в некотором смысле, следует из предыдущих. Это абстракция — способность скрывать детали различных реализаций некоторого функционала под общим интерфейсом и, затем использовать их в общем для всех реализаций коде. Собрав воедино код всех примеров с формами мы получим пример абстракции.

Заключение

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


25 апреля в OTUS состоится открытое занятие «Какие проблемы решает Rust?», на котором обсудим особенности Rust, выделяющие его на фоне других языков, его преимущества и недостатки. Ответим на вопрос, что он может предложить современной индустрии. Регистрация для всех желающих по ссылке.

Комментарии (25)


  1. Daniil_Palii
    20.04.2022 17:48
    -1

    trait - это ведь интерфейс? То есть в расте есть только наследование от интерфейса?


    1. Cheater
      20.04.2022 20:50
      +5

      trait - это ведь интерфейс?

      Похож, концепцию реализует ту же самую, но шире по возможностям (если сравнивать например с Java interface). Вот хорошее сравнение:

      StackOverflow - Is Rust trait the same as Java interface

      То есть в расте есть только наследование от интерфейса?

      Вопрос имхо не корректен.:) Есть "наследование от интерфейса" (trait implementation), есть ряд других техник для выполнения концепции наследования. Данные "наследуются" через композицию (Composition over inheritance). Трейты можно наследовать (Rust docs: Supertrait), но это не наследование интерфейсных классов в обычном понимании. Наследования в классическом смысле в Rust нет.


  1. amarao
    20.04.2022 18:03
    +1

    Вообще говоря, вместе с dyn trait, мы получаем настоящий эквивалент С++ классов (... окей, в том объёме как я его знаю, ибо С++##22 неисчерпаем как атом). virtual table есть? Остаётся вопрос с наследованием, и это как раз сущность, которую я стараюсь не использовать даже в языках, где она есть и факультативная, потому что чтение кода между двумя "разно наследованными" классами от одного базового приводит к stack overflow у кожанного мешка.


    1. F3kilo Автор
      21.04.2022 10:59

      Не совсем эквивалент... Наверное, если запретить базовым классам в плюсах иметь данные, то будет похоже.
      А вообще, согласен. Наследование нужно только тогда, когда оно, действительно, упрощает жизнь именно в контексте конкретной задачи. Хорошая задача на наследование: стоит ли квадрат наследовать от прямоугольника.
      С одной стороны, квадрат, действительно является прямоугольником, и, казалось бы, что может пойти не так?) Но, если в нашем контексте мы ожидаем, что для абстракции "прямоугольник", увеличение одной стороны пропорционально увеличивает площадь, то для квадрата это не сработает. В данном контексте нарушится LSP. Поэтому не нужно слепо руководствоваться внутренними ощущениями или сторонними фактами, продумывая архитектуру. Надо смотреть насколько то или иное решение стыкуется с конкретной задачей.


      1. hapcode
        21.04.2022 14:30
        -2

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


        1. nin-jin
          21.04.2022 16:03
          -2

          Отношение подтипизации может быть реализовано разными способами:

          • Через сужение типа: квадрат является частным случаем прямоугольника

          • Через расширение типа: прямоугольник является квадратом с дополнительным значением размера

          • Через пересечение: квадрат и прямоугольник являются подтипами абстрактной фигуры, характеризуемой координатами

          • Через объединение: квадрат и прямоугольник являются независимыми типами, а вот плита может быть и квадратом, и прямоугольником в зависимости от ракурса.


        1. hapcode
          21.04.2022 16:43

          Я говорил про наследование. Жесткое отношение «is-a». Какой смысл наследовать квадрат от прямоугольника, если не получается соблюдать инвариант пропорциональности квадрата? Квадрат не ведет себя полностью как прямоугольник. Не получится задать ему разную высоту и ширину как для прямоугольника.


      1. 0xd34df00d
        21.04.2022 18:24
        +1

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


      1. hapcode
        21.04.2022 19:04
        +1

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


        1. nin-jin
          21.04.2022 20:37
          -2

          Если это графический редактор, то очень даже хочется.


  1. Antervis
    20.04.2022 20:08
    -2

    Наследование описывает отношение "является" между двумя объектами

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


    1. sshikov
      20.04.2022 22:46

      1. funca
        20.04.2022 23:46
        -1

        Наследование в стиле C++ это в первую очередь техника повторного использования кода, чтобы не писать по десять раз одно и тоже, с возможностью подменять отдельные запчасти.

        LSP это просто популярный мем, ни какой особой роли он там не играет.


        1. SadOcean
          21.04.2022 02:16
          +1

          Так а ООП то тут при чем?

          От того, что наследование реализаций классов есть в плюсах и нет в расте и интерфейсах - как это ООП мешает?

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


          1. funca
            21.04.2022 06:22
            +2

            ООП не существует в вакууме. Вокруг него сложилась определенная культура с классами, SOLID, паттернами и т.п. Заявка о поддержке ООП создаёт у разработчиков определенные ожидания, которые превращаются в разочарования и сорванные сроки, если что-то вдруг идёт не так.

            JavaScript почти 20 лет прямым текстом всем доказывали, что наследование прототипов это тоже ООП. Но все успокоились только после того, как в язык завезли классы.

            В Rust аналогичная картина. Однако они не позиционируют себя как язык с полноценной поддержкой ООП, а честно предупреждают об особенностях, компромиссах и возможностях сделать иначе и лучше https://doc.rust-lang.org/book/ch17-00-oop.html


            1. SadOcean
              21.04.2022 11:45

              Справедливости ради, да, в растбуке хорошо написано про эдж кейсы

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


        1. sshikov
          21.04.2022 19:45
          +1

          Во-первых, я бы отметил, что LSP это вообще не про ООП, он про типы и подтипы, а они бывают много где. И в отличие от других паттернов из того же SOLID, нарушение LSP как раз очень часто просто сломает существующий код, не знающий про другое поведение наследников.

          >объект потомка не обязан «являться» корректным экземпляром родителя
          Вот такой случай как раз и есть нарушение LSP. И любой клиент, который заложится на корректность поведения, может сломаться.

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


      1. Antervis
        21.04.2022 03:40
        -2

        LSP это рекомендация по проектированию архитектуры приложения. Она не диктует ни то, что такое наследование, ни то, как оно может применяться. Повторюсь: я говорил про общий случай, а не распространенную конкретику.

        Простейший пример на c++:

        class Derived : Base {};

        Ну-ка, кто скажет почему при таком наследовании нет полиморфизма?


        1. funca
          21.04.2022 08:21
          +2

          The intuitive idea of a subtype is one whose objects provide all the behavior of objects of another type (the supertype) plus something extra. What is wanted here is something like the following substitution property [S]: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.

          ...We are using the words “subtype” and “supertype” here to emphasize that now we are talking about a semantic distinction. By contrast, “subclass” and “superclass” are simply linguistic concepts in programming languages that allow programs to be built in a particular way.

          В своей работе из 1987 Барбара и сотоварищи думали над формализацией отношения тип-подтип и в качестве отправной точки взяли определение из более ранней работы своего коллеги. Позднее они нашли в таком определении изъяны и пытались исправить, усложнив определение этого своего behaviour subtyping. Но в целом данная ветка исследований оказалась не особо практичной (как многое другое, пытающееся опираться на семантику внешнего мира). Сейчас все это представляет собой больше историческую ценность, может быть дающую какие-то инсайты и возможность не наступать на уже кем-то пройденные грабли. То как подаётся LSP в SOLID для адептов ООП это каргокульт в чистом виде.


          1. sshikov
            21.04.2022 19:49

            >возможность не наступать на уже кем-то пройденные грабли
            А вы считаете, этого мало? Ну в смысле, опять же — если вы умный, высококвалифицированный и т.п., и не наступаете и так (отчего бы и нет, вполне верю), это же не значит, что своему наследнику (человеку) нужно оставить проект, который он сломает, потому что окажется не таким умным, квалифицированным и т.п.? Разумеется, LSP не серебряная пуля, это в целом не более чем хорошая практика, если так делать — вы скорее всего не сломаете проект добавляя наследников.


            1. Antervis
              21.04.2022 20:55

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


              1. sshikov
                21.04.2022 21:09

                Я скорее возражал против некоторой расплывчатости вашей формулировки в первом комментарии данной ветки. Да, сейчас вижу что там было и про «в общем случае» — и с таким уточнением скорее согласен.


      1. nin-jin
        21.04.2022 06:59
        -2


    1. Kubera2017
      21.04.2022 02:01

      это override


  1. SadOcean
    21.04.2022 02:25
    +6

    В целом мне кажется, что раст можно назвать вполне поддерживающим ОО подход.

    Несмотря на отсутствия наследования реализаций (а это не единственный вариант организации связей) и некоторые особенности, в нем есть все нужное для организации кода в оо стиле - разбиение кода на независимые объекты со своим скрытым сложным внутренним состоянием и методами их обработки.