Object-oriented design is the roman numerals of computing.
— Rob Pike, автор Go.
Предлагаю вашему вниманию вольный перевод заметки «Is Go An Object Oriented Language?» за авторством Steve Francia, в которой автор наглядно рассказывает об особенностях использования парадигмы ООП в Go. Сразу предупреждаю, что из-за свойств оригинального материала большую часть текста пришлось переформулировать полностью, где-то добавить своего. Флажок перевода убирать не стал.
1. Введение
Так что же это значит, быть «объектно-ориентированным»? Обратимся к истории возникновения концепта ООП, попробуем разобраться.
Первый объектно-ориентированный язык, Simula, появился на горизонте в 60-x годах. Он привнёс понятия объектов, классов, понятия наследования и классов-потомков, виртуальных методов, сопрограмм и многое другое. Походу, самым ценным вкладом стала парадигма абстракции данных.
Вы можете быть не знакомы со Simula, но, вне всяких сомнений, точно знаете некоторые из тех языков, для которых он стал вдохновением — Java, C++, C# и Smalltalk, которые позже, в свою очередь, сильно повлияли на Objective-C, Python, Ruby, Javascript, Scala, PHP, Perl… полный перечень содержит почти все популярные современные языки. Эта парадигма настолько прочно вошла в нашу жизнь, что большинство современных программистов никогда и не думали иначе.
Поскольку общепринятого определения ООП не существует, для продолжения дискуссии мы сформулируем своё.
Вместо явного разделения кода и данных в тексте программы, объектно-ориентированная система объединяет их используя концепцию «объекта». Объектом называется абстрактный тип данных, включающий состояние (данные) и поведение (код).
Поскольку изначальная реализация имела механизмы наследования и полиморфизма, которые были переняты практически во всех производных языках, определения объектно-ориентированного программирования обычно включают их как необходимое условие.
Далее мы рассмотрим, как в Go используются объекты, полиморфизм и наследование, после чего будет проще ответить на поставленный вопрос.
2. Объекты?
В Go нет ничего с именем object
, хотя это и не важно. Пусть тип object
не встречается, зато есть тип, попадающий под определение объектно-ориентированного подхода — структуры данных, включающие и состояние и поведение, они обозначаются как struct
. struct это тип, содержащий именованные поля и методы.
Для наглядности приведу пример:
type rect struct {
width int
height int
}
func (r *rect) area() int {
return r.width * r.height
}
func main() {
r := rect{width: 10, height: 5}
fmt.Println("area: ", r.area())
}
Первый блок определяет новый тип rect
структурного типа, содержащего 2 целочисленных поля. Следующий блок определяет метод на этой структуре путём определения функции area
и прикрепления её к типу rect
. Точнее, на самом деле функция прикрепляется к типу-указателю на rect
.
Последний блок является точкой входа программы, это функция main. Первая строка создаёт новый экземпляр rect
(выбранный способ создания экземплара — через составной литерал — является наиболее удобным в Go). Вторая линия отвечает за вывод результатов вызова функции area
на значении r
.
Лично мне это всё сильно напомнило работу с объектами. Я могу создать тип структурированных данных и определить методы для работы с некоторыми из них.
Чего-то ещё не хватает? Да, в большинстве объектно-ориентированных языков для описания объектов используются классы с поддержкой наследования, причём хорошей практикой считается определение интерфейсов для этих классов и тем самым определение дерева иерархии классов (в случае простого наследования).
2. Наследование и полиморфизм
Существует несколько различных подходов к определению отношений между объектами. И хотя имеются некоторые различия, все они используются практически с одной целью — ради повторного использования кода.
2.1. Простое и множественное наследование
Наследование это механизм языка, позволяющий описать новый класс на основе уже существующего (базового) класса. Существует две разновидности наследования, на основании количества базовых классов. Принципиальную разницу можно ощутить лишь оценивая последствия применения множественного наследования: иерархия простого наследования (single inheritance) представляет собой дерево, в то время как множественное неследование порождает решётку. Языки с поддержкой исключительно простого включают PHP, C#, Java и Ruby, а к языкам с поддержкой множественного наследования относятся Perl, Python и C++.
2.2. Полиморфизм подтипов
В некоторых языках понятия подтипов и наследования так тесно переплетены, что разница между ними едва заметна. На самом деле подтипы определяют семантические отношения между двумя и более объектами, тем самым образуя отношения is-a. То есть, тип A является подтипом B тогда, когда спецификация A следует из спецификации B и любой удовлетворяющий спецификации A объект (или класс) также удовлетворяет спецификации B. В то время как простое наследование только повторно использует реализацию, тем самым обеспечивая синтаксический сахар, но не более.
Следует чётко различать наследование через реализацию и «наследование» через полиморфизм подтипов, что будет понятно из текста далее.
2.3. Композиция
При композиции один объект определяется путём включения в него других объектов, то есть вместо наследования он просто содержит их. Такой тип взаимосвязи называется has-a и включаемые объекты подчиняются правилам принадлежности.
3. Есть ли в Go наследование?
Go по задумке был спроектирован без наследования в обычном понимании этого слова. Вовсе не значит, что объекты (структуры) не имеют отношений, просто авторы языка выбрали иной механизм обозначения таковых. Для многих начинающих писать на Go это решение может показаться серьёзной недоработкой, однако, в действительности же, это одно из самых приятных свойств языка и оно решает много проблем, заодно закрывая споры вокруг наследования — возраст которых исчисляется десятками лет — раз и навсегда.
4. «Простое наследование лучше выкинуть»
Далее я приведу фрагмент из статьи на JavaWorld — «Why extends is evil»:
В книге банды четырёх о паттернах проектирования детально обсуждается замена наследования через реализацию (extends) на наследование через интерфейсы (implements).
Я однажды посетил сходку юзер группы Java, куда James Gosling (создатель Java) был приглашён делать доклад. Во время памятной сессии вопросов и ответов кто-то спросил его: «Если бы вы могли сделать Java заново, что бы вы изменили?». «Я бы выкинул классы», ответил он. После того как смех в зале утих, он объяснил, что настоящая проблема заключается не в классах по сути, а в наследовании через реализацию (отношение extends). Наследование же через интерфейсы (отношение implements) является предпочтительным, следует избегать наследование через реализацию там, где возможно.
5. Отношения объектов в Go
5.1. Композиция типов
Вместо обычного наследования в Go строго используется принцип композиции вместо наследования и отношения между структурами и интерфейсами строятся по принципам is-a и has-a. Используемый здесь механизм композиции объектов представляется встраиваемыми типами, так, Go позволяет встроить структуру в структуру, создавая при этом отношения типа has-a. Хорошим примером является связь между типами Person и Address в коде ниже:
type Person struct {
Name string
Address Address
}
type Address struct {
Number string
Street string
City string
State string
Zip string
}
func (p *Person) Talk() {
fmt.Println("Hi, my name is", p.Name)
}
func (p *Person) Location() {
fmt.Println("Im at", p.Address.Number, p.Address.Street, p.Address.City, p.Address.State, p.Address.Zip)
}
func main() {
p := Person{Name: "Steve"}
p.Address = Address{ Number: "13", Street: "Main" }
p.Address.City = "Gotham"
p.Address.State = "NY"
p.Address.Zip = "01313"
p.Talk()
p.Location()
}
Результат:
> Hi, my name is Steve
> Im at 13 Main Gotham NY 01313
В этом примере важно то, что Address остаётся обособленной сущностью, находясь внутри Person
. В функции main
продемонстрировано, как можно присвоить p.Address
объект адреса и, обращаясь к его полям через точку, проинициализировать его.
5.2. Имитация полиморфизма подтипов
Примечание автора. Первая версия данной статьи неверно объясняла реализацию отношения is-a через анонимные поля структуры. На деле же она лишь напоминает отношение is-a, поскольку включаемые методы и свойства становятся видимыми извне, как если бы они существовали во включающей структуре. Такой подход не является is-a по причинам, описанным далее, однако, в Go есть поддержка is-a и достигается она через интерфейсы. Текущая версия статьи ссылается на анонимные поля как на имитацию is-a, поскольку они похожи на механизм полиморфизма, но не более. //
Имитация отношения is-a работает подобным же образом. Пусть человек (Person
) умеет говорить. Горожанин (Citizen
) является человеком (Person
), а значит также умеет говорить (Talk
). Расширим предыдущий пример с учётом этого.
type Citizen struct {
Country string
Person // анонимное поле без имени
}
func (c *Citizen) Nationality() {
fmt.Println(c.Name, "is a citizen of", c.Country)
}
func main() {
c := Citizen{}
c.Name = "Steve"
c.Country = "America"
c.Talk()
c.Nationality()
}
Результат:
> Hi, my name is Steve
> Steve is a citizen of America
Мы наладили имитацию отношения is-a используя анонимное поле структуры, в данном случае поле Person
(указывается только тип) у Citizen
. Тип Citizen
приобрёл все свойства и методы типа Person
и имеет возможность дополнить или перекрыть некоторые из этих свойств и методов своими. Например, пусть городские жители будут отвечать немного иначе:
func (c *Citizen) Talk() {
fmt.Println("Hello, my name is", c.Name, "and Im from", c.Country)
}
Результат:
> Hello, my name is Steve and Im from America
> Steve is a citizen of America
Обратите внимание, что теперь в main
вызывается метод *Citizen.Talk()
вместо *Person.Talk()
.
6. Почему анонимные поля не дают полиморфизм
Имеются две причины.
6.1. Остаётся доступ к индивидуальным полям каждого из встроенных типов
Ну, на самом деле это не так уж и плохо, поскольку при множественном «наследовании» становится неочевидно, какой именно метод из родительских классов будет вызван. При использовании анонимного поля Go создаёт вспомогательное поле, дублируя имя типа, поэтому вы всегда сможете обратиться к индивидуальным методам всех анонимных полей, то есть базовых классов в нашей имитации наследовательного механизма. Пример:
func main() {
c := Citizen{}
c.Name = "Steve"
c.Country = "America"
c.Talk() // <- Метод доступен
c.Person.Talk() // <- Также доступен
c.Nationality()
}
Результат:
> Hello, my name is Steve and Im from America
> Hi, my name is Steve
> Steve is a citizen of America
6.2. Тип-потомок не становится типом-предком
Если бы полиморфизм был настоящий, анонимное поле заставило бы включающий тип стать включаемым типом, но в Go и включаемый и включающий до конца остаются раздельными. Лучше один раз увидеть, приведём пример:
type A struct {
}
type B struct {
A // B is-a A
}
func save(A) {
// xxx
}
func main() {
b := &B{}
save(b) // OOOPS! b IS NOT A
}
Результат:
> prog.go:17: cannot use b (type *B) as type A in function argument
> [process exited with non-zero status]
Данный пример предложен в этом комментрии с Hacker News. Спасибо, Optymizer.
7. Настоящий полиморфизм подтипов
Интерфейсы в Go — штука довольно уникальная по своей природе. В данном разделе мы сосредоточимся на применении интерфейсов для реализации полиморфизма, что не является их первоочередной задачей.
Как я уже писал раньше, полиморфизм это отношение is-a. В Go каждый тип является обособленным и ничто не может замаскироваться под другой тип просто так, но зато оба типа могут подходить под один и тот же интерфейс. Интерфейсы можно передавать как параметры в функции и методы и это позволит нам установить настоящее отношение is-a между типами.
Факт соответствия типа интерфейсу проверяется автоматически, путём сравнения наборов требуемых интерфейсом методов и набора имеющихся методов у проверяемого типа. Иными словами, такое отношение можно описать как «если что-то может делать это, то оно может использоваться здесь», то есть имеет место быть принцип утиной типизации.
Возвращаясь к предыдущему примеру, добавим новую функцию SpeakTo
и в main
попробуем применить её поочерёдно к экземплярам Citizen
и Person
.
func SpeakTo(p *Person) {
p.Talk()
}
func main() {
p := &Person{Name: "Dave"}
c := &Citizen{Person: Person{Name: "Steve"}, Country: "America"}
SpeakTo(p)
SpeakTo(c)
}
Результат:
> Running it will result in
> prog.go:48: cannot use c (type *Citizen) as type *Person in function argument
> [process exited with non-zero status]
Как и предполагалось, конструкция просто неверна. В данном случае Citizen
не является Person
, даже не смотря на то, что у них общие свойства. Однако, добавим интерфейс Human
, сделаем его принимаемым типом функции SpeakTo
и вот, всё пошло по плану.
type Human interface {
Talk()
}
func SpeakTo(h Human) {
h.Talk()
}
func main() {
p := &Person{Name: "Dave"}
c := &Citizen{Person: Person{Name: "Steve"}, Country: "America"}
SpeakTo(p)
SpeakTo(c)
}
Результат:
> Hi, my name is Dave
> Hi, my name is Steve
Подытоживая можно сделать два важных замечания про полиморфизм в Go:
- Используя анонимные поля можно добиться соответствия типа интерфейсу, в том числе одновременного соответствия набору интерфейсов, тем самым достигая почти канонического полиморфизма.
- Go может обеспечить полиморфизм подтипов во время использования значения (
c.Name = "Xlab"
), но в действительности же все типы являются обособленными. Как показано в последнем примере, чтобы установить свойствоName
уCitizen
, мы обязаны явно указать это свойство уPerson
, присвоив затем объектPerson
соответствующему полю уCitizen
. (речь о составном литералеCitizen{Person: Person{Name: "Steve"}, Country: "America"}
).
8. Итоги
Как вы могли наблюдать в примерах выше, фундаментальные принципы объектно-ориентированного подхода актуальны и успешно применяются в Go. Существуют отличия в терминологии, поскольку используются несколько иные механизмы, нежели в других классических языках ООП. Так, для объединения состояния и поведения в одной сущности используются структуры с заданными (прикреплёнными) методами. Для обозначения отношений has-a между типами используются композиция, тем самым минимизируя количество повторений в коде и избавляя нас от бардака с классическим наследованием. Для установления is-a отношений между типами в Go используются интерфейсы, без лишних слов и контрпродуктивных описаний.
Вот так, встречайте новую модель объектно-ориентированного программирования — без объектов!
~ translation & adaptation by Xlab.
См. также: «Повторное использование кода в Go на примере»
Кстати, на всякий случай приведу здесь эту цитату:
I invented the term Object-Oriented and I can tell you I did not have C++ in mind.
— Alan Kay
Автор: Xlab