Написав несколько проектов на Go, я оглянулся назад. Посмотрел на язык, на его плюсы и минусы. В этой статье хотелось бы поговорить о том, за что критикуют Go. Конечно же, речь пойдет об отсутствии ООП как такового, перегрузки методов и функций, обобщенного программирования и исключений. Действительно ли это доставляет столько проблем? Или это проблема подхода разработки? Я поделюсь своим опытом решения этих вопросов.
Проблематика
Я стал программировать на Go после Java и PHP. И сейчас расскажу почему.
Java классная штука. У нее приятный синтаксис. Многие крупные проекты используют его в бою. Все было бы круто, если не JVM. Для того, чтобы развернуть серьёзное приложение на Java, вам понадобится тачка с большим количеством оперативной памяти. И это совершенно не годится для спартапов.
В свою очередь в PHP при каждом запросе приложение инициализируется заново, нет многопоточности из коробки, ниже производительность.
У каждого языка программирования, да и у каждой технологии, есть свои слабые стороны. Всегда чем то приходится жертвовать — производительностью, ресурсами системы, сложностью разработки.
Go в этом плане не исключение. Выдавая неплохую производительность и экономно расходуя ресурсы, язык требует от разработчика особого подхода. Если подходить к разработке приложений на Go, как на Java, то тогда действительно возникнут трудности. Мы сталкиваемся с отсутствием таких привычных вещей, как ООП, перегрузка методов и функций, обобщенное программирование, а также исключений.
Когда нет привычных решений, на смену им приходят новые, но не менее эффективные: гибкая система типов, интерфейсы, разделение данных и поведения. Сейчас я продемонстрирую все на примерах.
Перегрузка методов и функций
В Go нет перегрузки методов и функций. Предлагается просто давать разные имена методам и функциям.
func SearchInts(a []int, x int) bool
func SearchStrings(a []string, x string) bool
Иной подход — это использование интерфейсов. Для примера создадим интерфейс и функцию для поиска:
type Slice interface {
Len() int
Get(int) interface{}
}
Search(slice Slice, x interface{}) bool
Теперь достаточно создать два типа:
type Ints []int
type Strings []string
И реализовать интерфейс в каждом из типов. После этого можно использовать поиск и по строкам и по числам:
var strings Strings = []string{"one", "two", "three"}
fmt.Println(Search(strings, "one")) // true
fmt.Println(Search(strings, "four")) // false
var ints Ints = []int{0, 1, 2, 3, 4, 5}
fmt.Println(Search(ints, 0)) // true
fmt.Println(Search(ints, 10)) // false
ООП
В Go нет того ООП, к которому мы так привыкли. ООП в Go это по сути встраивание типов с возможностью перегрузки методов родителя методами потомка. Пример:
// родитель
type A struct {}
// может быть переопределен в потомке
func (a *A) CallFirst() {
fmt.Println("A CallFirst")
}
// потомок
type B struct {
A
}
// переопределяем метод в потомке
func (b *B) CallFirst() {
fmt.Println("B CallFirst")
}
a := new(A)
a.CallFirst() // "A CallFirst"
b := new(B)
b.CallFirst() // "B CallFirst"
В этом случае все работает так, как необходимо. Как поступить, если нам нужна реализация метода в родительском типе, работа которого зависит от переопределенных в потомке методов? Добавляем в родительский тип метод со сложной логикой и несколько методов для переопределения:
// метод со сложной логикой
func (a *A) CallSecond() {
fmt.Println(a.GetName(), a.GetMessage())
}
// может быть переопределен в потомке
func (a *A) GetName() string {
return "A"
}
// может быть переопределен в потомке
func (a *A) GetMessage() string {
return "CallSecond"
}
// переопределяем метод в потомке
func (b *B) GetName() string {
return "B"
}
a.CallSecond() // “A CallSecond”
b.CallSecond() // “A CallSecond”, а нужно “B CallSecond”
Я выбрал для себя такое решение — создаем и реализуем интерфейс для родителя и потомка. При вызове сложного метода передаем ссылку на интерфейс и в родителе и в потомке:
// создаем интерфейс
type SuperType interface {
GetName() string
GetMessage() string
CallSecond()
}
// метод со сложной логикой
func (a *A) сallSecond(s SuperType) {
fmt.Println(s.GetName(), s.GetMessage())
}
// реализуем метод интерфейса в родителе
func (a *A) CallSecond() {
a.callSecond(a)
}
// реализуем метод в потомке
func (b *B) CallSecond() {
b.callSecond(b)
}
a.CallSecond() // “A CallSecond”
b.CallSecond() // “B CallSecond”
Вам может, что это не совсем элегантно, зато мы избегаем дублирование логики. Кроме того интерфейсы понадобятся, когда метод или функция в качестве аргумента будут принимать обобщенный тип:
// создадим еще одного потомка
type C struct {
A
}
func (c *C) GetName() string {
return "C"
}
func (c *C) CallSecond() {
c.callSecond(c)
}
// функция, которая должна работать с A, B и C
func DoSomething(a *A) {
a.CallSecond()
}
DoSomething(a)
DoSomething(b) // ошибка, не тот тип
DoSomething(c) // ошибка, не тот тип
Переделаем функцию DoSomething так, что бы она принимала интерфейс:
// функция, которая должна работать с A, B и C
func DoSomething(s SuperType) {
s.CallSecond()
}
DoSomething(a) // “A CallSecond”
DoSomething(b) // “B CallSecond”
DoSomething(c) // “C CallSecond”
Таким образом мы отделяем данные от поведения, что является хорошей практикой.
Обобщенное программирование
В Go все таки есть обобщенное программирование и это interface{}. Опять же это непривычно, т.к. нет синтаксического сахара, как в Java.
ArrayList<String> list = new ArrayList<>();
String str = list.get(0);
Что же мы получаем в Go?
type List []interface{}
list := List{"one", "two", "three"}
str := list[0].(string)
На мой взгляд разница не велика! Если же использовать интерфейсы, то можно избежать явного приведения типов. Приведу пример:
// создаем интерсейсы
type Getter interface {
GetId() int
}
type Setter interface {
SetId(int)
}
// обобщаем интерфейсы
type Identifier interface {
Getter
Setter
}
// создаем новый список
type List []Identifier
Добавим несколько типов, которые будут реализовывать Identifier и функции, которые будут работать с интерфейсами.
// реализует Identifier
type User struct {
id int
}
// реализует Identifier
type Post struct {
id int
}
func assign(setter Setter, i int)
func print(getter Getter)
Теперь мы можем пройтись циклом по массиву без явного приведения типов
list := List{new(User), new(User), new(Post), new(Post), new(Post)}
for i, item := range list {
assign(item, i)
print(item)
}
Обработка ошибок
Блок try/catch отсутствует в языке, вместо этого методы и функции должны возвращать ошибку. Кто то считает это недостатком языка, кто то нет. Есть люди, которые принципиально не используют try/catch, т.к. это медленный блок. Как правило хватает стандартной обработки ошибок:
func Print(str string) error
if Print(str) == nil {
// делаем что то еще
} else {
//обработка ошибки
}
Если же нужна сложная обработка ошибок, как в блоке try/catch, то можно воспользоваться следующим приемом:
switch err := Print(str); err.(type) {
case *MissingError:
// обработка ошибки
case *WrongError:
// обработка ошибки
default:
// делаем что то еще
}
Таким образом с помощью конструкции switch можно свести в минимуму отсутствие try/catch.
Подводя итоги
Итак, действительно ли язык имеет такие серьезные проблемы? Я думаю, нет! Это вопрос привычки! Для меня Go — это оптимальное решение, когда надо написать системную утилиту, сетевого демона или веб приложение, которое способно обработать десятки тысяч запросов в секунду. Go молод, но очень перспективен, хорошо решает свои задачи. Go предлагает новый подход, к которому нужно привыкнуть, обжиться в нем, чтобы чувствовать себя комфортно. Я это уже сделал, советую и вам!
P.S.: полный код примеров доступен здесь.
Автор: asolomonoff