Вступление

Я вернулся!

Надеюсь, у вас был хороший праздник. Но теперь пора вернуться к Rust и обсудить некоторые философские вопросы. Вы ведь читаете эти посты, чтобы поговорить о философии, верно?

Связывание данных с поведением

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

struct Point {
 x: f32,
 y, f32
}
impl Point {
 // here we declare our methods
}

В Rust нет специальных методов-конструкторов. Общепринятая модель - иметь методы конструктора для вашего типа данных. То есть методы, которые получают 0 или могут аргументы и возвращают объект самого типа.

impl Point {
 fn new(x_arg: f64, y_arg: f64) -> Point {
   Point {
     x: x_arg,
     y: y_arg
   }
 }
 
 fn zero() -> Point {
   Point {
     x: 0.0,
     y: 0.0
   }
 }
}

У нас уже есть несколько моментов, на которые стоит обратить внимание. Во-первых, в Rust нет функции или перегрузки метода (то есть функции с тем же именем, но с другой подписью). Ага, шокирующий, мне тоже потребовалось время, чтобы смириться с этим. Это означает, что мы не можем перегрузить метод new без параметров для создания новой точки в начале координат. Хорошая вещь в том, что в конечном итоге вы будете вынуждены иметь методы с понятным именем. Во-вторых, мы можем немного упростить здесь вот так:

// …
 fn new(x: f64, y: f64) -> Self {
   Point { x, y } // sugar for Point { x: x, y: y}
 }
 
 fn zero() -> Self {
   // …
 }
// …

Итак, вот что изменилось. Во-первых, сигнатуру методов конструктора (или заводских методов) мы заменили имя типа на Self. Это своего рода метатип, который означает «контекст типа, в котором я нахожусь». Поскольку мы реализуем методы для Point, тип Self в этом контексте будет означать Point. Этот метатип становится действительно полезным, когда мы начинаем говорить о дженериках.

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

Вызвать фабричные методы также просто. Мы используем синтаксис <TypeName>::<method_name>(...) для вызова функции method_name, связанной с типом TypeName. Символ :: отличает вызов вызывающих функций, связанных с типами, от функций, связанных с экземплярами (которые мы вскоре увидим). Например, создать новый Point с помощью объявленных нами методов так же просто, как

let point = Point::new(1.0, 2.0);

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

impl Point {
 fn sum(a: u32, b: u32) -> u32 {
   a + b
 }
}

Хотя это было бы немного глупо и не соответствовало бы хорошей практике.

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

impl Point {
 fn dividing_by(&self, d: f64) -> Self { 
   Point {
     x: self.x / d,
     y: self.y / d
   }
 }
 
 fn div_by_locally(&mut self, d: f64) {
   self.x /= d;
   self.y /= d;
 }
 
 fn div_by_globally(self, d: f64) -> Self {
   Point {
     x: self.x / d,
     y: self.y / d
   }
 }
}

Здесь мы реализовали 3 различных метода деления. Вызвать методы в экземпляре так же просто, как <instance_name>.<method_name>(...). Обратите внимание, что здесь мы используем . вместо ::. Это связано с тем, что :: будет искать функции, связанные с типом, а . будет искать функции (или поля), связанные с экземпляром. Параметр self неявно передается методу, когда мы его вызываем.

Общее у них - это первый параметр self. Методы экземпляра принимают этот первый (и всегда первый) параметр для обозначения экземпляра, в котором вызывается метод. У нас есть 4 способа взять параметр self:

  • self: будет принимать экземпляр в качестве значения. Это означает, что экземпляр не будет доступен для использования после вызова этого метода (он будет использован / перемещен в метод);
  • mut self: точно так же, как self, но мы принимаем его как изменяемое значение, чтобы мы могли изменять данные экземпляра внутри метода;
  • & self: мы берем неизменяемую ссылку на экземпляр, поэтому внутри метода мы не можем изменять содержимое экземпляра, но потом мы не потребляем экземпляр;
  • & mut self: аналогично &self, но мы можем изменять содержимое экземпляра внутри метода.

Обратите внимание, что для mut вариантов нам в первую очередь нужен mut доступ к экземпляру:

let point = Point::new(0.0, 1.0);
let _ = point.dividing_by(1.0); // ok, we take it by an immutable 
                                // reference and the original 
                                // contents are not changed
point.div_by_locally(1.0); // not ok, we do not have mutable access
                           // to `point`
let mut mutable_point = Point::new(0.0, 1.0); // a mutable point
mutable_point.div_by_locally(1.0); // ok, we have mutable access to 
                                  // `point`
let p2 = point.div_by_globally(1.0); // ok, we can take point by 
                                     // value and move into the 
                                     // method
let p3 = point.div_by_globally(1.0); // not ok, `point` was moved in 
                                     // the last call and doesn’t   
                                     // exist anymore

Аккуратный.

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

Черты

Но что, если мы хотим обобщить возможности, которые могут иметь типы? Например, наш тип Point может быть «втянут» в некоторую поверхность, поэтому мы могли бы добавить метод draw к реализациям.

impl Point {
 // …
 fn draw(&self, surface: &mut Surface) {…}
}

Это круто и работает, но можно рисовать не только точки, поэтому, если у нас есть тип линии

struct Line {
 start: Point,
 end: Point
}

нам также потребуется реализовать draw метод для него ... ну, если вы знакомы с концепцией интерфейсов, вы знаете, к чему я клоню. В Rust нет точных интерфейсов, но есть нечто похожее, называемое чертами, которые намного круче. Начнем с самого начала. Черта представляет собой способность типа

trait Drawable {
 fn draw(&self, surface: &mut Surface);
}

Здесь мы объявляем, что есть черта с именем Drawable, у которой есть метод draw. Мы можем реализовать это для некоторого типа следующим образом

impl Drawable for Point {
 fn draw(&self, surface: &mut Surface) {…}
}
impl Drawable for Line {
 fn draw(&self, surface: &mut Surface) {…}
}

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

impl Drawable for u32 {…} // don’t ask me how this would work

u32 определен в стандартной библиотеке Rust, но мы все еще можем изменить его поведение с помощью трейтов. Единственное ограничение состоит в том, что признак или тип (или оба) должны быть определены в одном и том же модуле. Это означает, что мы не можем реализовать трейт из модуля A для типа в модуле B, находясь в модуле C. Это сделано для того, чтобы избежать конфликтов реализации, когда мы начнем работать с зависимостями. Но пока мы храним все в одном модуле.

Еще одна вещь, которая делает систему трейтов Rust более мощной, чем простые интерфейсы, - это то, что мы можем реализовать наши трейты для типов, которых даже не знаем!

trait Printable {
 fn print(&self)
}
impl <T> Printable for T {
 fn print(&self) {
 println!(“Hello!”)
 }
}
fn main() {
 let x = 0;
 x.print();
}

Здесь мы получаем немного общей системы типов Rust, которая будет подробно рассмотрена в следующей статье, но относительно ясно, что означает этот код: реализовать Printable для каждого типа T.

Небольшое замечание: мы могли бы упростить реализацию как

trait Printable {
 fn print(&self) {
   println!(“Hello!”)
 }
}
impl <T> Printable for T {}

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

Возвращаясь к реализации универсальных шаблонов (известных как общие реализации), они представляют собой действительно мощный инструмент, который следует использовать с осторожностью. В примере Printable, если бы пользователь хотел реализовать черту Printable для своего собственного типа с другой реализацией, он не смог бы это сделать, поскольку эта реализация будет конфликтовать с реализацией бланкета. Другое дело, что это действительно широкое одеяло, которое мы используем (т.е. для каждого T), почти бесполезно. Внутри нашей реализации у нас нет абсолютно никакой информации о нашем типе T, который можно было бы использовать для чего-нибудь интересного. Более полезный метод - использование общих реализаций с границами типов. Это означает, что мы ограничиваем набор типов, к которым T применяется наша общая реализация.

trait Paintable {
 fn paint(&mut self, color: Color)
}
impl <T: Paintable> Drawable for T {
 fn draw(&self, surface: &mut Surface) {
   self.paint(Color(0, 0, 0, 1));
   // do drawing logic here
 }
}

Теперь это круто! Но имейте в виду, что мы снова сталкиваемся с проблемой, заключающейся в том, что если пользовательский код хочет реализовать Paintable и Drawable с их собственной реализацией, он не сможет это сделать, поэтому используйте эту стратегию с осторожностью. Прекрасным примером того, как это эффективно используется, являются черты From и Into из стандартной библиотеки. Эти особенности преобразования имеют примерно следующие сигнатуры:

trait From<T> {
 fn from(T) -> Self;
}
trait Into<T> {
 fn into(self) -> T;
}

Мы представляем здесь новый синтаксис с <T>, и, как вы понимаете, это связано с универсальными шаблонами. А пока вам просто нужно понимать это как «какой-то тип T». From trait создает новый экземпляр вашего типа из некоторого типа T, а Into берет ваш тип и превращает его в какой-то тип T. У этой черты есть эффект зеркального отражения. Имеет смысл, что если у нас есть реализация From<T> for U, то это почти то же самое, что и реализация Into<U> for T (не торопитесь, чтобы подумать об этом, глядя на приведенные выше методы). Действительно, стандартная библиотека предоставляет следующую довольно тривиальную реализацию бланкета:

impl<T, U: From<T>> Into<U> for T {
 fn into(self) -> U {
   U::from(self)
 }
}

Опять же, не торопитесь, чтобы понять, о чем идет речь. Для каждого типа `U`, который реализует From<T>, мы автоматически получаем реализацию Into<U> для соответствующего T.

Еще одна изящная реализация бланка в трейте From довольно тривиальна.

impl <T> From<T> for T {
 fn from(value: T) -> T {
   value
 }
}

Хотя это выглядит действительно глупо, в некоторых общих контекстах оно может пригодиться.

ООП и ржавчина

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

Rust не является языком ООП, но концепции могут быть отображены в ООП.

Означает ли это, что Rust уступает языкам ООП, потому что вы не можете делать все?

Означает ли это, что он лучше, потому что в нем нет подводных камней ООП-языков (привет моим ребятам из области FP!)?

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

Но что такое ООП? Википедия говорит

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

Это первоначальное определение идеально подходит для Rust. У нас есть структуры, которые содержат поля данных, и мы можем связать методы с этими структурами. Просто мы традиционно не называем эти структуры объектами. Все идет нормально. Теперь давайте рассмотрим, какие функции могут быть у языков ООП.

На основе классов и на основе прототипов

В языках, основанных на классах, классы определяются заранее, и объекты создаются на основе классов. Если два объекта - яблоко и апельсин - созданы из класса Fruit, они по своей сути являются фруктами, и вы гарантированно сможете обращаться с ними таким же образом; например программист может ожидать существования таких же атрибутов, как color, sugar_content или is_ripe.

В языках, основанных на прототипах, объекты являются первичными сущностями. Никаких классов даже не существует. Прототип объекта - это просто еще один объект, с которым этот объект связан. У каждого объекта есть одна ссылка на прототип (и только одна). Новые объекты могут быть созданы на основе уже существующих объектов, выбранных в качестве их прототипа. Вы можете назвать два разных объекта яблоком и апельсином фруктом, если объект фрукт существует, а яблоко и апельсин имеют фрукт в качестве своего прототипа. Идея фруктового класса существует не явно, а как класс эквивалентности объектов, имеющих один и тот же прототип. Атрибуты и методы прототипа делегируются всем объектам класса эквивалентности, определенного этим прототипом. Атрибуты и методы, которыми индивидуально владеет объект, не могут совместно использоваться другими объектами того же класса эквивалентности; например Атрибут sugar_content может неожиданно отсутствовать в яблоке. Через прототип можно реализовать только однократное наследование.

Из них подход Rust больше похож на проектирование на основе классов. Опять же, даже несмотря на то, что мы не называем их объектами, у нас все же есть какая-то форма отношений, использующих черты.

Динамическая отправка / передача сообщений

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

Вызов метода также известен как передача сообщений. Он концептуализирован как сообщение (имя метода и его входные параметры), передаваемое объекту для отправки.

С типажами и трейт-объектами мы также получаем (одиночную) динамическую отправку.

Инкапсуляция

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

Если класс не позволяет вызывающему коду обращаться к внутренним данным объекта и разрешает доступ только через методы, это сильная форма абстракции или сокрытия информации, известная как инкапсуляция. Некоторые языки (например, Java) позволяют классам явно устанавливать ограничения доступа, например, обозначая внутренние данные с помощью ключевого слова private, а методы, предназначенные для использования кодом вне класса, с помощью ключевого слова public. Методы также могут быть разработаны на общедоступном, частном или промежуточном уровнях, например, защищенном (что позволяет получить доступ из одного и того же класса и его подклассов, но не из объектов другого класса). В других языках (например, Python) это применяется только по соглашению (например, частные методы могут иметь имена, начинающиеся с подчеркивания). Инкапсуляция предотвращает взаимодействие внешнего кода с внутренней работой объекта. Это облегчает рефакторинг кода, например, позволяя автору класса изменять способ представления данных объектами этого класса внутри без изменения какого-либо внешнего кода (при условии, что вызовы «общедоступных» методов работают одинаково). Это также побуждает программистов помещать весь код, связанный с определенным набором данных, в один и тот же класс, который упорядочивает его для облегчения понимания другими программистами. Инкапсуляция - это метод, который способствует разделению.

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

Лично я бы сказал, что это одна из слабых сторон ООП не потому, что она действительно плохая, а потому, что ею неправильно пользуются. Концепция инкапсуляции используется в крайних случаях в языках ООП (я смотрю на вас двоих, Java и C ++), где считается хорошей практикой (и во многих случаях требуется в Java, если вы хотите иметь дело с экосистемой beans) иметь частные поля с геттерами и сеттерами для него даже для простых классов данных.

На самом деле большинство типов, которые мы пишем, являются простыми типами носителей, используемыми для логической группировки данных. Довольно глупо количество шаблонов, которые мы должны написать на этих языках, чтобы достичь чего-то, что должно быть простым во имя своего рода «защитного» программирования. Действительно, другие языки ООП, такие как C #, заключают такие концепции в свойства, где общий случай (поле носителя данных должно быть доступно) легко представить, с штриховкой для реализации геттеров и сеттеров с тем же синтаксисом доступа к полю. Сама Java движется к более простому типу данных, вводя записи, которые удаляют большую часть шаблонов создания простых типов.

Но на мой взгляд, более важным, чем сокрытие данных, является управляемая изменчивость. Еще раз проводя параллель с Java и C ++, где у нас есть два разных подхода, Rust работает аналогично C ++.
Rust, как и C ++, может ограничивать уровень изменчивости, некоторый «объект» передается функции (или другому объекту), но в отличие от C ++ он по умолчанию неизменяем (по умолчанию const, в терминах C ++) . Эта неизменяемость также распространяется на внутренние данные, поэтому, если у нас есть неизменное «представление» объекта, эта неизменяемость является рекурсивной, и мы не можем получить доступ к подполям изменчивым образом.

Если вы пришли с Java, это серьезное отличие. Самое близкое, что есть в Java, - это final fields, где мы не можем изменить то, на что ссылаются эти поля, но у нас нет способа ограничить изменчивость того, на что указывает эта ссылка. В мире Java это решается с помощью инкапсуляции.

Мы поговорим больше о изменчивости и владении в одной из следующих статей.

Состав, наследование и делегирование

Объекты могут содержать другие объекты в своих переменных экземпляра; это известно как композиция объекта. Например, объект в классе Employee может содержать (напрямую или через указатель) объект в классе Address в дополнение к своим собственным переменным экземпляра, таким как «first_name» и «position». Композиция объекта используется для представления отношений «имеет-а»: у каждого сотрудника есть адрес, поэтому каждый объект Employee имеет доступ к месту для хранения объекта Address (либо непосредственно встроенного в себя, либо в отдельном месте, адресуемом через указатель) .

Языки, поддерживающие классы, почти всегда поддерживают наследование. Это позволяет классам быть организованными в иерархию, которая представляет отношения типа «есть тип». Например, класс Employee может быть унаследован от класса Person. Все данные и методы, доступные для родительского класса, также появляются в дочернем классе с такими же именами. Например, класс Person может определять переменные first_name и last_name с помощью метода make_full_name (). Они также будут доступны в классе Employee, который может добавлять переменные «должность» и «зарплата». Этот метод позволяет легко повторно использовать одни и те же процедуры и определения данных в дополнение к потенциально интуитивному отображению реальных отношений. Вместо того, чтобы использовать таблицы базы данных и подпрограммы программирования, разработчик использует объекты, с которыми пользователь может быть более знаком: объекты из области их приложения. [9]

Подклассы могут переопределять методы, определенные суперклассами. На некоторых языках разрешено множественное наследование, хотя это может усложнить разрешение переопределений. Некоторые языки имеют специальную поддержку миксинов, хотя на любом языке с множественным наследованием миксин - это просто класс, который не представляет отношения типа «есть тип». Примеси обычно используются для добавления одних и тех же методов к нескольким классам. Например, класс UnicodeConversionMixin может предоставлять метод unicode_to_ascii () при включении в класс FileReader и класс WebPageScraper, которые не имеют общего родителя.

Абстрактные классы не могут быть преобразованы в объекты; они существуют только с целью наследования другим «конкретным» классам, экземпляры которых могут быть созданы. В Java ключевое слово final может использоваться для предотвращения разделения класса на подклассы.

Доктрина композиции над наследованием выступает за реализацию отношений типа «есть-а» с использованием композиции вместо наследования. Например, вместо наследования от класса Person, класс Employee может предоставить каждому объекту Employee внутренний объект Person, который затем имеет возможность скрыть от внешнего кода, даже если класс Person имеет много общедоступных атрибутов или методов. Некоторые языки, например Go, вообще не поддерживают наследование.

«Принцип открытости / закрытости» утверждает, что классы и функции «должны быть открытыми для расширения, но закрытыми для модификации».

Делегирование - это еще одна языковая функция, которую можно использовать как альтернативу наследованию.

Это было долго. Здесь у нас есть то, о чем думает большинство людей, когда они думают о том, что такое ООП и где Rust перестает быть языком ООП. Rust не поддерживает наследование данных. Как мы уже видели ранее, он предоставляет концепцию как разновидность сквозных черт. Важно отметить, что многие современные передовые практики в ООП говорят об интерфейсных API и композиции, а не наследовании.

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

Также следует отметить, что вы _ можете_ смоделировать поведение ООП, сочетая композицию и (ab), используя черту Deref. Об этом мы поговорим в следующем посте.

Полиморфизм

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

Например, объекты типа Circle и Square являются производными от общего класса Shape. Функция Draw для каждого типа Shape реализует то, что необходимо для рисования самого себя, в то время как вызывающий код может оставаться безразличным к конкретному типу Shape рисуется.

Это еще один тип абстракции, который упрощает код, внешний по отношению к иерархии классов, и обеспечивает четкое разделение задач.

Когда мы говорим об ООП, полиморфизм и наследование идут рука об руку. Rust может обрабатывать полиморфизм через свою систему признаков и объекты признаков, которые будут обсуждаться в нашей следующей публикации. А пока достаточно сказать, что он работает так же, как C ++. Мы можем рассуждать об указателях / ссылках на черты характера и не заботиться о том, какая фактическая структура стоит за реализацией. Во время выполнения будет вызвана правильная реализация методов трейта, которые будут работать должным образом.

Итак, Rust - это язык ООП?

Это зависит от того, что вы ищете в ООП. Вы заботитесь только о наследовании данных? Тогда нет, Rust не является языком ООП.

Вы заботитесь об инкапсуляции и интерфейсах? Да, вы можете сделать это в Rust.

А теперь стоит ли писать код на Rust так же, как на других языках ООП? Возможно нет. У Rust есть и другие сильные стороны, на которых вам следует сосредоточиться, но это не значит, что вам никогда не следует использовать динамическую отправку. Инструменты доступны для вас, и вы обязаны использовать их правильно.

Заключение

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

В следующем посте мы поговорим о признаке Deref и о том, как его использовать для имитации некоторой формы наследования данных, а также об объектах признаков. После этого мы поговорим на любом языке об одной из моих любимых тем: о дженериках!

До скорого.