Некоторые мысли о проектировании библиотек (с примерами на Go)
- среда, 25 марта 2026 г. в 00:00:08
Программисты много пользуются библиотеками. Но спроектировать библиотеку сложно. В этой статье я изложу некоторые соображения по поводу того, как создавать библиотеки.
Для начала разграничим, чем нам приходится заниматься при программировании. Попробуем представить акты программирования как беседы. Далее разберём, какие основные виды деятельности складываются в «программирование», как его принято понимать. Всё это послужит нам основой для разработки более качественных программных библиотек.
Итак, рассмотрим, что делается при программировании. Основная цель программирования — создавать программы, код которых приносит пользу. Всё дальнейшее — это уже бюрократия. Современное программирование делится на две большие части: написание приложений и написание библиотек (в качестве поддержки), которые это приложение использует.
Кроме того, акт программирования можно представить так: программист одновременно ведёт два диалога с машиной. Первый — диалог с компилятором, чтобы создать кастомизированную среду выполнения, а второй — диалог с системой выполнения, в ходе которого сообщает ей, что делать.
В том же духе, как при уподоблении программирования двум беседам, можно осмыслить и написание библиотек. Написание библиотек также напоминает сразу два разговора. Первый разговор программист ведёт с компьютером, а второй — с другим человеком (обычно с самим собой будущим). В этой статье, обсуждая свойства библиотек, мы рассмотрим две этих разновидности бесед.
На следующей блок-схеме отражены те представления о программировании, которые я изложил выше.

Но сначала давайте вернёмся к основам и задумаемся, зачем нужны библиотеки. Зачем мы пишем программные библиотеки? Какая от них польза?
Для начала отмечу, что термин «библиотека» я употребляю вместо терминов «пакеты», «модули» и «репозитории». Несмотря на то, что они зачастую используются как полные синонимы, на мой взгляд, между этими понятиями есть тонкие отличия. Позвольте я объясню.
Репозиторий — это подборка файлов, в которых содержится исходный код. Как правило, они организуются в виде каталогов, складывающихся в файловую систему.
Библиотека — это подборка ресурсов для совместного использования, обычно это исходный код. Совместно использовать код можно разными способами. Например, в Go это обычно делается при помощи пакетов, которые язык поддерживает на уровне спецификации.
Можно совместно использовать код и другими способами. Ниже — искусственный пример, при помощи которого я проиллюстрирую мой тезис.
Допустим, у меня есть файл (назовём его lib.go) в каталоге под названием common.
func MaxInt(a, b int) int { if a > b { return a } return b }
Я начинаю разрабатывать новый пакет Go (под названием foo) и кладу его в каталог под названием github.com/myusername/bar. Копирую lib.go из common в bar и переименую файл в bar, давая ему новое название lib_bar.go. Далее я отредактирую lib_bar.go и прикреплю в самом его начале объявление package foo. В результате полная версия lib_bar.go примет вид:
package foo func MaxInt(a, b int) int { if a > b { return a } return b }
Допустим, далее я стану разрабатывать новый пакет Go (под названием ‘baz’) и положу его в каталог под названием bitbucket.org/myusername/quux. Я скопирую lib.go из common в quux и переименую файл в quux так, чтобы он назывался lib_baz.go. Далее я прикреплю объявление package baz в самом верху полного lib_baz.go, чтобы он читался так:
package baz func MaxInt(a, b int) int { if a > b { return a } return b }
Давайте подведём предварительные итоги:
в common у меня неправильно оформленный файл .go под названием lib.go
в github.com/myusername/bar у меня файл под названием lib_foo.go. В этом репозитории содержится пакет foo.
в github.com/myusername/quux у меня лежит файл под названием lib_baz.go. В этом репозитории содержится пакет baz.
Как видите, я выложил исходный код из common/lib.go для совместного использования в два разных пакета: foo и baz. Да, совместное использование обеспечивается путём копирования, то есть у нас нет единого источника истины. Но в описываемый момент исходный код уже является разделяемым.
Именно в этом смысле я и употребляю слово «библиотека» — набор разделяемого исходного кода, предназначенного для совместного использования.
В общем случае библиотеки с исходным кодом на Go организуются в виде пакетов и модулей. Пакет — это набор файлов .go. Обычно пакет делает ровно одну вещь. Пакет может зависеть от другого пакета.
Внимательный читатель укажет мне, что, при наличии lib.go в таком виде как в предыдущем примере любой проект на Go не скомпилируется, а выдаст ошибку. В самом верху любого файла .go необходимо объявлять, для чего используется этот пакет. Делая объявление package foo, мы сообщаем компилятору, что этот файл нужно включить в пакет.
Если пакет — это набор файлов, в которых содержится исходный код, то модуль — это набор пакетов. Модули в Go проектировались именно для решения проблем, возникающих с зависимостями пакетов. Модули в Go определяются в файле go.mod, где перечислены все те пакеты, от которых зависит модуль.
Таким образом, разобравшись со всеми терминами, можем вновь обдумать вопрос: почему именно библиотеки?
Можно написать приложение (в самом верху которого будет волшебное объявление package main) и положить в этот главный пакет все наши структуры данных и код.
Но в правилах грамотного программирования специально подчёркивается, что так делать не надо. Важно добиться декомпозиции и изоляции.
Помните, выше я говорил, что, когда пишешь библиотеку — ты одновременно как будто ведёшь два разговора. Декомпозиция (и обратный процесс, композиция) призвана облегчить разговор с машиной. Изоляция должна облегчить разговор с другим человеком. Композицию мы подробнее обсудим ниже в этой статье.
В основе изоляции кода лежит следующий общий принцип: компонентам одной библиотеки не должно быть известно о компонентах другой библиотеки. Следовательно, программист, работающий над конкретной библиотекой, должен знать лишь о том, что именно делает эта библиотека. То есть, он не будет при разработке отвлекаться от библиотеки, с которой имеет дело.
Я очень вольно определил «библиотеку» как совокупность ресурсов, рассчитанных на совместное использование. Обычно эти ресурсы представляют собой исходный код — но не всегда. Я приведу два примера других внешних ресурсов, которые вкладываются в библиотеки.
В качестве первого примера рассмотрим обработку с применением CUDA.
Допустим, вы работаете с графической картой nVidia и хотели бы использовать её в качестве GPGPU применительно к CUDA. Ресурс, к которому вам нужен доступ — это GPU. Доступ приобретается при помощи драйверов CUDA. В Go есть библиотека cu, входящая в состав комплекта библиотек Gorgonia. Она управляет как драйвером, так и обращениями к устройству.
Доступ можно получить при помощи следующего кода:
d := cu.CurrentDevice() ctx := cu.NewContext(d, cu.SchedAuto|cu.MapHost) mem, err := ctx.MemAlloc(1024)
ctx — это дескриптор для GPU. Имея такой дескриптор, можно отправлять рабочие задачи для выполнения на GPU (например, зарезервировать 1 МБ графической карты, как в рассматриваемом примере). Но, в конечном счёте, ctx — это ресурс.
Во втором примере рассмотрим, как использовать в библиотеке файлы в качестве ресурса.
Допустим, у меня есть список полнотекстовых пьес Шекспира в формате ASCII, и я хочу поместить их в библиотеку. Я могу сохранить пьесы как файлы в формате .txt и сложить их в одном централизованном месте, где к ним можно будет обращаться.
Или же, чтобы довести до максимума совместимость с языком Go, можно создать новый пакет, содержимое которого будет выглядеть примерно так:
package willshakes const AllsWellThatEndsWell = `Act 1 Scene 1 COUNTESS. In delivering my son from me, I bury a second husband. BERTRAM. And I in going, madam, weep o'er my father's death anew: but I must attend his majesty's command, to whom I am now in ward, evermore in subjection. ... ` const MacBeth = `Act 1 Scene 1 FIRST WITCH. When shall we three meet again In thunder, lightning, or in rain? SECOND WITCH. When the hurlyburly's done, When the battle's lost and won. THIRD WITCH. That will be ere the set of sun. ...
Таким образом, достаточно всего лишь импортировать willshakes — и для обращения к тексту Макбета нам потребуется просто воспользоваться willshakes.MacBeth.
Разница между двумя примерами — с CUDA и с Шекспиром — в том, что в примере с CUDA ресурс является динамическим, а в примере с Шекспиром — статическим.
Термины «статический» и «динамический» здесь не очень хороши, но именно так их принято использовать. Чтобы стало понятнее, позвольте объяснить ещё немного:
Ресурс является статическим, если его состояние известно во время компиляции. Ресурс является динамическим, если во время компиляции его состояние не известно.
Следовательно, ресурс с текстами Шекспира является статическим, поскольку во время компиляции весь корпус текстов известен и доступен. В свою очередь, графическая карта может быть доступна или недоступна в зависимости от ситуации, и это приходится определять во время выполнения. Поэтому графическая карта является динамическим ресурсом.
Ещё один хороший пример (с которым, правда, мне на практике сталкиваться не доводилось) — включение шрифтов в программу в составе набора статических ресурсов.
На момент подготовки оригинала этой статьи существовало предложение допустить встраивание статических ресурсов в готовый двоичный файл с программой на Go, так что на стороне Go последнее слово в этой истории ещё не сказано.
Не вдаваясь в обсуждение конкретных деталей библиотек, или в какой форме они могут фигурировать (пакеты, драйверы, модули, т.д.), поговорим о том, какие типы библиотек мы уже успели затронуть выше.
В широком смысле все библиотеки делятся на два больших класса:
Библиотеки, в которых основным совместно используемым ресурсом является исходный код.
Библиотеки, в которых основным совместно используемым ресурсом является что-то другое.
Вторую категорию можно разделить ещё на две подкатегории:
Библиотеки драйверов.
Библиотеки ресурсов.
Библиотека драйверов обычно представляет собой пакет, в котором обёрнут доступ к драйверу. Например, в вышеупомянутом пакете cu завёрнуты драйверы CUDA, позволяющие программировать под CUDA на Go. Аналогичным образом устроен пакет go-gl, опосредующий доступ к OpenGL). В обоих пакетах предоставляются дополнительные вспомогательные функции, упрощающие «переход между мирами», но в фундаментальном отношении это пакеты с драйверами.
В свою очередь, willshakes — это пример библиотеки с ресурсами. Если вас интересует более реалистичный пример, рекомендую рассмотреть библиотеку mnist. В силу самой природы данных исходного кода, а также из-за невозможности обрабатывать в Go статические ресурсы, пакет спроектирован просто как сборник исходного кода, загружающего данные из внешнего файла в структуру данных. В мире Python это делается иначе, поскольку программист может сразу же (без подготовки) использовать keras.datasets.mnist как ресурс.
Теперь, когда мы определились, что именно может входить в библиотеку, давайте разберёмся, каковы качества хорошей библиотеки. Начнём со сравнительно простых и очевидных утверждений, а затем перейдём в область более тонких нюансов. Несмотря на кажущуюся простоту, даже тривиальные утверждения подразумевают оговорки, которые мы также кратко исследуем.
Я выделяю несколько базовых качеств, обладающая которыми библиотека является хорошей:
1. Надёжная
2. Лёгкая в использовании/сборке
3. Удобная в переиспользовании
Прежде всего, библиотека должна быть надёжной. Что толку в библиотеке, если она не выполняет заявленных функций? Признаков надёжной библиотеки много, они перечислены ниже.
Хорошая библиотека чётко обозначает, чем именно она ценна. Как правило, такая библиотека хорошо делает что-то одно или предоставляет конкретный ресурс. Что есть «что-то одно» — обычно дискуссионный вопрос.
Например, рассмотрим библиотеку grpc. Она занимается конкретно gRPC. Но в составе gRPC есть множество субкомпонентов, два основных из них — это сервер и клиент.
Примеры другой крайности наблюдаются во многих пакетах, которыми изобилует npm. Так, пакет left-pad предоставлял одну функцию для заполнения строки нулями. Он делал ровно одну вещь, и от него зависели многие другие пакеты. Следовательно, когда пакет left-pad убрали из доступа, сломался весь Интернет.
Хорошая библиотека как следует протестирована. Те, кто пользуется библиотекой, должны в ней не сомневаться.
Есть множество уровней тестирования — модульное, интеграционное, на основе свойств, фаззинговое и другие. У каждого есть свои достоинства и недостатки.
Я бы даже рекомендовал пользоваться лишь такими библиотеками, которые были протестированы на основе свойств или методом фаззинга.
Если библиотеку можно считать хорошо протестированной, то следует проверить, покрыты ли тестами лишь специализированные случаи или общие тоже. Именно поэтому я предпочитаю библиотеки, протестированные методом фаззинга или на основе свойств. При фаззинг-тестировании убеждаемся, что библиотечные функции могут справляться с непредвиденным вводом, тогда как тестирование на основе свойств требует глубоко понимать предметную область.
При всём сказанном, если вы разрабатываете библиотеки драйверов, то тестировать такие библиотеки может быть несколько сложно. Сделаны некоторые успехи в фаззинге исходников при работе с библиотеками драйверов, но я не нашёл таких хороших и универсально применимых паттернов для тестирования библиотек драйверов, которые удачно вписывались бы в мой рабочий процесс.
Хорошая библиотека не управляет ресурсами за пользователя, а предоставляет ему утилиты для этой цели.
Например: если вы пишете библиотеку, использующую контекст OpenGL для каких-то операций над OpenGL, не создавайте в этой библиотеке контекст OpenGL. Вместо этого обяжите пользователя самостоятельно передать контекст OpenGL.
То же верно и для аллокаций. По возможности не выделяйте память от имени пользователя.
Дэйв Чини в своё время написал максимально отличную статью на тему принудительных аллокаций. Заголовок может немного вводить в заблуждение, но точка зрения автора похожа на ту, что я высказываю здесь.
Когда библиотека не управляет ресурсами за пользователя, становится очевидно, что пользователь должен управлять ресурсами сам. Груз ответственности ложится на плечи пользователя, зато библиотека получается более надёжной.
Наконец, немаловажно отметить, что касается Go — не порождайте горутины от имени пользователя.
Хорошую библиотеку легко использовать. Причём это качество может проявляться многогранно.
Хорошая библиотека хорошо документирована. Если вы считаете, что «тесты — это документация», то да, вы правы. Так, в Go хорошо поддерживаются примеры, служащие одновременно документацией и тестами. Мне нравится работать с библиотеками на Go, где достаточно зайти в файл godoc — и вот тебе примеры.
Паники допустимы только в тех случаях, когда лучшего варианта нет. Как правило, лучше возвращать ошибки.
При паниках мы вырываем управление из рук пользователя. Лучше дать пользователю возможность самому обработать поток управления.
Этот вопрос откровенно спорный, особенно в контексте Общей Картины, предлагаемой в этой статье (см. ниже раздел «Натяжка»). Но, как мне кажется, в хорошей библиотеке — минимум зависимостей.
Это особенно справедливо для тех библиотек, в которых основным разделяемым ресурсом является исходный код. Если библиотека, создававшаяся для совместного использования кода, будет зависеть от какой-то родительской библиотеки, мне это покажется очень подозрительным.
Кроме того, каждая дополнительная зависимость усложняет работу с библиотекой. Я часто проверяю, что именно импортирует каждая библиотека, так как хочу удостовериться, что мои импорты не станут внезапно «звонить домой» на какой-то сервер. Я не привередлив на этот счёт лишь потому, что в противном случае пришлось бы слишком много всего проверять.
В Go есть поговорка, что нулевое значение у любого типа данных должно быть полезным. В таком случае удаётся обойтись без слишком сложных функций конструктора. Очень хороший пример — тип mat.Dense из библиотеки Gonum.
В типе данных mat.Dense есть метод Mul, выполняющий перемножение матриц. Вот какая сигнатура у этого типа:
func (m *Dense) Mul(a, b Matrix)
Результат операции a × b помещается в m. Следовательно, если a — это матрица (2,3), а b — это матрица (3,2), то m будет матрицей (2,2). В документации это описано не слишком ясно, поэтому большинство программистов попробуют сделать как-то так:
c := mat.NewDense(2, 2, make([]float64, 4)) c.Mul(a, b)
На практике следующий вариант тоже сработает:
var c mat.Dense c.Mul(a, b)
Хорошую библиотеку также можно переиспользовать в различных сценариях. В Go основным аргументом в пользу дженериков часто служит именно фактор переиспользования.
Эта истина уже навязла в зубах (взять хотя бы этот пост о том, как использовать интерфейсы в Go), что не помешает мне повторить её ещё раз: принимайте интерфейсы, возвращайте структуры.
Позвольте пользователям вашей библиотеки расширять функции и поведение содержащихся в ней объектов. В Go это достигается, прежде всего, за счёт компонуемости типов данных.
Что подводит меня к следующему тезису -
Ключевое свойство библиотеки общего назначения — лёгкость компоновки с другими библиотеками. Да, библиотеки компонуются. Если ограничиться рассмотрением пакетов (то есть библиотек, предназначенных прежде всего для совместного использования исходного кода), то логическим завершением будут модули в стиле MLton (не путайте их с модулями Go).
Учитывая, как в MLton (и SML) спроектированы их системы модулей, естественно, возникает вопрос об этике «Чёткого ценностного предложения» — я имею в виду, что библиотеки обычно очень маленькие. Кроме того, в системах модулей этих языков определяется вспомогательная функция, как раз обеспечивающая компоновку модулей.
Итак, как же в Go компоновать пакеты? Давайте вообразим альтернативный Go, где модули строятся в стиле MLton. Ближайший эквивалент в современных реалиях был бы таким: пакеты экспортируют только интерфейсы и функции. Внутри пакета вы по-прежнему можете писать соответствующие типы данных, но экспортировать их не можете. Каков тогда будет итоговый результат?
На таком альтернативном языке можно было бы создавать объекты, которые исключительно хорошо компонуются друг с другом. Правило «принимать интерфейсы, возвращать структуры» уже не столько правило, сколько необходимость — ведь язык обязывает вас делать это. В структуры встраиваются не конкретные типы, а интерфейсы.
Если все конкретные типы данных из пакета A принимаются функциями из пакета B, то говорят, что пакеты A и B можно скомпоновать.
В Go это происходит как есть. На следующей схеме показано, какие пакеты, содержащиеся в моём GOPATH можно скомпоновать друг с другом. Исходный код, на основе которого генерируется этот граф, выложен здесь.
Стрелки указывают на интерфейс, определённый вне пакета (то есть это зависимость). Отсюда можно вывести измеримый параметр, позволяющий судить, насколько поддаётся компоновке вся экосистема Go. Размер указывает полустепень захода — а именно, сколько пакетов реализуют интерфейсы данного пакета. Это мера соответствия функций данной библиотеки закону Постела: «Будь либерален к тому, что принимаешь, и требователен к тому, что отсылаешь». Цветами обозначены классы модульности.
Из 762 пакетов, присутствующих у меня в GOPATH, сюда включены лишь 120, на что потребовалось 62 ребра. Они образуют 56 модулей. Оставшиеся 600+ пакетов пришлось исключить как не поддающиеся компоновке. Кстати, в рамках данного анализа я исключил интерфейсы, определённые в стандартной библиотеке, так как просто не знаю, как загружать их для анализа (вероятно, нужно сделать что-то связанное с types.Universe).
Идея группировать софтверные библиотеки по признаку их компонуемости поначалу кажется странной, но это только на первый взгляд. В тех языках, где абстракции ценятся превыше всего (напр., в Haskell) такой ход рассуждений — норма. Я не утверждаю, что именно так и следует поступать. Напротив, я просто предлагаю по-новому подходить к проектированию библиотек.
Эта метрика важна. Например, за выстраиванием этого графа я также заметил, что библиотеки из Gorgonia поддаются компоновке друг с другом не так хорошо, как мне поначалу казалось.
Внимательный читатель наверняка сразу подметил, что между теми принципами, которые я привожу в качестве характеристик хорошей библиотеки, существует явная натяжка.
Хорошая и надёжная библиотека не управляет ресурсами за пользователя. Однако обычно именно из-за этого работа с библиотекой осложняется.
Вернёмся к примеру с Gonum, рассмотренному выше в разделе о полезности нулевых значений.
Повторю здесь первую часть этого примера:
c := mat.NewDense(2, 2, make([]float64, 4)) c.Mul(a, b)
Как видите, этот код весьма соответствует принципу «Не управляйте ресурсами за вашего пользователя». Вместо этого пользователю приходится самостоятельно создать *Dense и выделить значение (именно для этого здесь присутствует make([]float64, 4) ).
В Gonum предлагается более удобная пользователю альтернатива, показанная во второй части примера:
var c mat.Dense c.Mul(a, b)
Но в данном случае нарушается принцип «Не управлять ресурсами за пользователя».
Более вопиющий пример прилагается к моей собственной библиотеке tensor. В *tensor.Dense есть метод Mul, определяемый со следующей сигнатурой:
func (t *Dense) Mul(other *Dense, opts ...FuncOpt) (*Dense, error)
По умолчанию библиотека tensor управляет памятью за пользователя. Но в ней есть функциональные опции, на уровне которых можно модифицировать поведение Mul.
Вот, например, как можно вручную управлять аллокациями:
a := tensor.New(tensor.WithShape(2,3), tensor.WithBacking([]float64{...})) b := tensor.New(tensor.WithShape(3,2), tensor.WithBacking([]float64{...})) foo := tensor.New(tensor.WithShape(2,2), tensor.Of(tensor.Float64)) c, err := a.Mul(b, T.WithReuse(foo))
Результат c абсолютно такой же как и foo.
Итак, почему же я пошёл на такие натяжки и похвастался здесь двумя «плохими» примерами?
Поскольку, чтобы это перестало казаться натяжками, нужно рассмотреть более обширную картину.
Попробуем помыслить масштабнее. В широком смысле пакет tensor универсально обрабатывает разные типы данных и сохраняет такую универсальность при разных вычислениях. Это полезно при обращении с рабочими нагрузками в области глубокого обучения, например, такими, как обрабатывает Gorgonia.
Так, тот же пример, что приведён выше, можно повторить и с данными типа float32, обсчитываемыми на GPU:
type Engine struct { tensor.StdEng ctx cu.Context *cublas.Standard } // Движок реализует `tensor.Engine` e := newEngine() a := tensor.New(tensor.WithShape(2, 3), tensor.WithEngine(e), tensor.Of(tensor.Float32)) b := tensor.New(tensor.WithShape(3, 2), tensor.WithEngine(e), tensor.Of(tensor.Float32)) c := tensor.New(tensor.WithShape(2, 2), tensor.WithEngine(e), tensorlOf(tensor.Float32)) // заполнить значения a и b // ... _, err := a.Mul(b, tensor.WithReuse(c))
Теперь, когда общая картина прояснилась, можно обдумать, где возможно пойти на компромиссы. Сейчас имеем:
Библиотека tensor задумана для универсальной обработки всех типов данных и работает независимо от типа вычислений.
Не следует управлять ресурсами за пользователя.
Библиотека должна быть проста в использовании.
Библиотека должна быть расширяемой.
Если отдать предпочтение «не управлять ресурсами за пользователя», то сразу же приходится отказаться от «простоты в использовании» и «расширяемости».
Но, если не отдать предпочтение «не управлять ресурсами за пользователя», то любой, кто захочет воспользоваться пакетом tensor при работе с CUDA, может превратно подумать, что заданное по умолчанию поведение Mul будет работать и в CUDA. А это не так — программа выдаст панику, поскольку обращение к памяти организовано кустарно.
Чтобы сгладить напряжение, я рассмотрел различные варианты использования. Рассудил, что наиболее типичный будет таким: пакет tensor задействуется на ЦП, причём работать приходится с хорошо известными типами данных, такими как float64 и float32.
Иерархически выстроил потребности, отдав наивысший приоритет использованию GPU. Из-за этого приходится частично пожертвовать лёгкостью использования, но я считаю, что, если вы собираетесь работать с GPU, то вы пользователь-эксперт.
Иногда, правда, такое напряжение неустранимо. В таких случаях лучше выстраивать иерархическое семейство библиотек.
Наработав опыт программирования на разных языках, я заметил ряд хороших общих паттернов. Качественные библиотеки обычно организованы иерархически — одни надстраиваются над другими по принципу компоновки.
Напомню, что создание качественных библиотек — залог написания полезной программы. С вашей стороны будет очень любезно предоставить пользователю удобную библиотеку, готовую для применения в окончательной версии программы.
Соответственно, следует написать семейство библиотек. Каждая библиотека строится на основе более фундаментальной библиотеки. Чем выше мы поднимаемся по ступеням этой иерархии, тем уже становится поле использования каждой следующей библиотеки. Сужая круг возможных вариантов использования, мы всё проще можем решаться на автоматическое управление, снимая эту задачу с пользователя.
Обратите внимание, что это ортогонально концепции абстрагирования. Притом, что мы действительно обходим иерархию библиотек по направлению снизу вверх, сами библиотеки на этом пути становятся всё абстрактнее. Но это не обязательное условие. Абстрагирование деталей — тема для другой статьи.
В экосистеме Go как таковой не так много «семейств» библиотек. Вот некоторые из них:
Думаю, в целом хорошо, что семейств библиотек не так много. Обратите внимание, какие именно классы проблем решают эти библиотеки. Gorgonia обслуживает задачи глубокого обучения. Gonum — это семейство библиотек для работы с числами. Go-HEP решает задачи в области физики высоких энергий. Fyne работает с задачами, касающимися графического пользовательского интерфейса. Не сомневаюсь, что, если заглянуть в под-экосистемы, опосредующие работу с Docker или Kubernetes, то и там найдутся такие семейства.
При проектировании семейств библиотек мы рискуем увязнуть в оверинжиниринге. Это также тема для другой статьи.
Приняв решение, хорошо документируйте те компромиссы, на которые пришлось пойти. Для каждой библиотеки на каждом уровне нужно перечислить все такие компромиссы.
Особенно актуально это для средних уровней.
Основная мысль этой статьи – в том, что проектировать библиотеки сложно. Нужно учесть сразу множество соображений. Ещё раз перечислю здесь их все:
· Что войдёт в состав библиотеки?
· Какие типы библиотек у нас будут?
· Хорошая библиотека надёжна и обладает следующими свойствами:
o Делает ровно одну вещь/Предоставляет один ресурс.
o Хорошо протестирована
o Не управляет ресурсами за пользователей.
· Хорошую библиотеку легко использовать, поскольку она:
o Хорошо документирована и содержит примеры
o Не вызывает паник
o Обладает минимальными зависимостями
o (Именно в Go): делает нулевое значение полезным
· Хорошая библиотека универсальна:
o Функции в ней принимают интерфейсы и возвращают структуры
o Она расширяема
o Хорошо взаимодействует с окружением
· При проектировании библиотеки опирайтесь на широкий контекст
· Рассмотрите разные варианты использования
· Попробуйте сделать семейство библиотек
· Чётко опишите все сделанные компромиссы
A Philosophy of Software Design
Designing and Evaluating Reusable Components
How to Design a Good API and Why It Matters
Огромное спасибо Игону Эльбре, Брендону Стиллитано, Даррелу Чуа и Гэри Миллеру за редактирование черновиков этой статьи и за то, что подсказали мне ценные источники.