понедельник, 4 ноября 2019 г.

Golang в Google: дизайн языка на службе разработки программ, часть 5

Rob Pike, 2012

Продолжение, начало в части 1, части 2, части 3, части 4.

14. Композиция, а не наследование

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

Вместо этого у Go есть интерфейсы, идея, которая далее кратко описана.

В Go интерфейс - это просто набор методов. Например, вот определение интерфейса Hash из стандартной библиотеки.

type Hash interface {
    Write(p []byte) (n int, err error)
    Sum(b []byte) []byte
    Reset()
    Size() int
    BlockSize() int
}

Все типы данных, которые реализуют эти методы, неявно удовлетворяют этому интерфейсу; нет implements декларации. Тем не менее, удовлетворенность интерфейса статически проверяется во время компиляции, поэтому отвязывающие (decoupling) интерфейсы безопасны для типов.

Тип обычно удовлетворяет многим интерфейсам, каждый из которых соответствует подмножеству своих методов. Например, любой тип, который удовлетворяет интерфейсу Hash, также удовлетворяет интерфейсу Writer:

type Writer interface {
    Write(p []byte) (n int, err error)
}

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

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

Одним из крайних примеров является ядро ​​Plan 9, в котором все элементы данных системы реализованы точно в одном интерфейсе, API файловой системы, определенный 14 методами. Это единообразие допускало уровень композиции объектов, редко достигаемый в других системах, даже сегодня. Примеров предостаточно. Вот один из них: система может импортировать (в терминологии Plan 9) стек TCP на компьютер, который не имеет TCP или даже Ethernet, и через эту сеть подключиться к машине с другой архитектурой процессора, импортировать свое дерево /proc и запустить локальный отладчик для отладки точки останова удаленного процесса. Такая операция была обычным делом для Plan 9, ничего особенного. Способность делать такие вещи выпала из дизайна; это не требовало особой договоренности (и все было сделано на простом C).

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

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

Рассмотрим интерфейс Writer, показанный выше, который определен в пакете io: любой элемент, имеющий метод Write с этой сигнатурой, хорошо работает с дополнительным интерфейсом Reader:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Эти два взаимодополняющих метода позволяют создавать безопасные цепочки с богатым поведением, как обобщенные каналы Unix. Файлы, буферы, сети, шифраторы, компрессоры, кодировщики изображений и т.д. Могут быть соединены вместе. Процедура ввода-вывода в формате Fprintf использует io.Writer, а не, как в C, FILE*. Отформатированный принтер не знает, о чем пишет; это может быть кодировщик изображения, который, в свою очередь, пишет в компрессор, который, в свою очередь, пишет в шифратор, который, в свою очередь, пишет в сетевое соединение.

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

Также обратите внимание, что устранение иерархии типов также устраняет форму иерархии зависимостей. Удовлетворение интерфейса позволяет программе расти органично без заранее определенных контрактов. И это линейная форма роста; изменение интерфейса влияет только на непосредственных клиентов этого интерфейса; нет поддерева для обновления. Отсутствие implements деклараций беспокоит некоторых людей, но позволяет программам расти естественно, изящно и безопасно.

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

func ReadAll(r io.Reader) ([]byte, error)

Оболочки (wrappers) - функции, которые принимают интерфейс и возвращают интерфейс - также широко распространены. Вот несколько прототипов. LoggingReader регистрирует каждый вызов Read на входящем Reader. LimitingReader прекращает чтение после n байтов. ErrorInjector помогает тестировать, имитируя ошибки ввода/вывода. И еще много других.

func LoggingReader(r io.Reader) io.Reader
func LimitingReader(r io.Reader, n int64) io.Reader
func ErrorInjector(r io.Reader) io.Reader

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

15. Ошибки

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

Ключевой особенностью языка для обработки ошибок является предопределенный тип интерфейса, называемый error, который представляет значение, которое имеет метод Error, возвращающий строку:

type error interface {
    Error() string
}

Библиотеки используют тип error, чтобы вернуть описание ошибки. В сочетании с возможностью для функций возвращать несколько значений, легко вернуть вычисленный результат вместе со значением ошибки, если оно есть. Например, эквивалентный getchar в C не возвращает внеполосное значение в EOF и не генерирует исключение; он просто возвращает значение ошибки рядом с символом, при этом нулевое значение ошибки означает успех. Вот сигнатура метода ReadByte типа bufio.Reader буферизованного пакета ввода-вывода:

func (b *Reader) ReadByte() (c byte, err error)

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

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

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

f, err := os.Open(fileName)
if err != nil {
    return err
}

Кроме того, если ошибки используют специальные структуры управления, обработка ошибок искажает поток управления для программы, которая обрабатывает ошибки. Java-стиль блоков try-catch-finally чередует несколько перекрывающихся потоков управления, которые взаимодействуют сложным образом. Несмотря на то, что Go, напротив, делает более подробным проверку ошибок, явный дизайн обеспечивает прямой контроль над процессом - буквально.

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

16. Инструменты

Программная инженерия требует инструментов. Каждый язык работает в среде с другими языками и множеством инструментов для компиляции, редактирования, отладки, профилирования, тестирования и запуска программ.

Синтаксис Go, система пакетов, соглашения об именах и другие функции были разработаны для упрощения написания инструментов, а библиотека включает лексер, анализатор и средство проверки типов для языка.

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

Наиболее известным из них является gofmt, форматировщик исходного кода Go. С самого начала проекта мы планировали форматировать программы Go на компьютере, исключая целый класс споров между программистами: как мне выложить свой код? Gofmt запускается на всех программах Go, которые мы пишем, и большая часть сообщества с открытым исходным кодом также использует его. Он запускается как "предварительная проверка" для репозиториев кода, чтобы удостовериться, что все отмеченные в Go программы отформатированы одинаково.

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

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

Первым примером был флаг -r (rewrite) на самом gofmt, который использует простой язык сопоставления с образцом для включения перезаписей на уровне выражения. Например, однажды мы ввели значение по умолчанию для правой части выражения среза: сама длина. Все исходное дерево Go было обновлено, чтобы использовать это значение по умолчанию с помощью одной команды:

gofmt -r 'a[b:len(a)] -> a[b:]'

Ключевым моментом в этом преобразовании является то, что, поскольку вход и выход находятся в каноническом формате, единственные изменения, внесенные в исходный код, являются семантическими.

Подобный, но более сложный процесс позволил использовать gofmt для обновления дерева, когда язык больше не требовал точек с запятой в качестве завершителей операторов, если оператор заканчивался символом новой строки.

Другим важным инструментом является gofix, который запускает модули переписывания дерева, написанные на самом Go, которые, следовательно, способны к более сложному рефакторингу. Инструмент gofix позволил нам внести радикальные изменения в API и языковые функции, приведшие к релизу Go 1, включая изменение синтаксиса для удаления записей с карты, радикально отличающийся API для манипулирования значениями времени и многое другое. Когда эти изменения были введены, пользователи могли обновить весь свой код, выполнив простую команду

gofix

Обратите внимание, что эти инструменты позволяют нам обновлять код, даже если старый код все еще работает. В результате, репозитории Go легко обновляются по мере развития библиотек. Старые API могут быть устаревшими быстро и автоматически, поэтому необходимо поддерживать только одну версию API. Например, мы недавно изменили реализацию буфера протокола Go, чтобы использовать функции "getter", которых раньше не было в интерфейсе. Мы запустили gofix для всего кода Go в Google, чтобы обновить все программы, использующие буферы протокола, и теперь используется только одна версия API. Подобные радикальные изменения в библиотеках C++ или Java практически невозможны в масштабах кодовой базы Google.

Наличие пакета синтаксического анализа в стандартной библиотеке Go также позволило использовать ряд других инструментов. Примеры включают инструмент go, который управляет построением программы, включая получение пакетов из удаленных репозиториев; программа извлечения документов godoc, программа для проверки соблюдения соглашения о совместимости API при обновлении библиотеки и многое другое.

Хотя подобные инструменты редко упоминаются в контексте языкового проектирования, они являются неотъемлемой частью экосистемы языка, и тот факт, что Go был разработан с учетом инструментов, оказывает огромное влияние на развитие языка, его библиотек и его сообщества.

Заключение

Использование Go растет внутри Google.

Его используют несколько крупных пользовательских служб, в том числе youtube.com и dl.google.com (сервер загрузки, который обеспечивает загрузку Chrome, Android и других загрузок), а также наш собственный golang.org. И, конечно же, многие маленькие делают это, в основном, с использованием встроенной поддержки Google App Engine для Go.

Многие другие компании также используют Go; список очень длинный, но некоторые из наиболее известных:

  • BBC Worldwide
  • Canonical
  • Heroku
  • Nokia
  • SoundCloud

Похоже, что Go достигает своих целей. Тем не менее, еще рано говорить об успехе. У нас пока нет достаточного опыта, особенно с большими программами (миллионы строк кода), чтобы узнать, окупились ли попытки построить масштабируемый язык. Хотя все показатели положительные.

В меньшем масштабе некоторые незначительные вещи не совсем верны и могут быть изменены в более поздней (Go 2?) версии языка. Например, существует слишком много форм синтаксиса объявления переменных, программисты легко путаются с поведением нулевых значений внутри ненулевых интерфейсов, и есть много деталей библиотеки и интерфейса, которые могут использовать другой раунд проектирования.

Тем не менее, стоит отметить, что gofix и gofmt дали нам возможность исправить многие другие проблемы во время подготовки к Go версии 1. Go, как сегодня, намного ближе к тому, чего хотели дизайнеры, чем это было бы без этих инструментов, которые сами были включены дизайном языка.

Однако не все было исправлено. Мы все еще учимся (но язык пока зафиксирован).

Существенным недостатком языка является то, что реализация все еще нуждается в работе. Сгенерированный код компилятора и производительность среды выполнения, в частности, должны быть лучше, и работа над ними продолжается. Уже есть прогресс; на самом деле, некоторые тесты показывают удвоение производительности в версии для разработчиков сегодня (конец 2012 года) по сравнению с первым выпуском Go версии 1 в начале 2012 года.

Резюме

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

Свойства, которые привели к этому, включают:

  • ясные зависимости
  • ясный синтаксис
  • ясная семантика
  • композиция, а не наследование
  • простота, обеспечиваемая моделью программирования (сборка мусора, конкурентность)
  • легкие инструменты (инструмент go, gofmt, godoc, gofix)

Если вы еще не попробовали Go, мы советуем вам это сделать.


Читайте также:


Комментариев нет:

Отправить комментарий