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

Вот лишь некоторые плоды моего многолетнего труда:

  • Memcache++;
  • Cpp-Netlib;
  • LLVM XRay.

Что же такое проектирование библиотек и почему так важно выполнить его должным образом?

Предыстория

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

2005 год. Я, начинающий инженер-программист, работал тогда у провайдера мобильного контента на Филиппинах. Наше предприятие предоставляло довольно востребованную услугу, объединявшую электронную почту с SMS и MMS. В те времена написанием высокомасштабируемых и чувствительных к производительности систем занимались единицы инженеров-программистов, да и литературы для таких, как я, проработавших в отрасли около года, было не так много.

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

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

Именно тогда один из моих первых наставников показал мне фреймворк ACE, который представлял собой набор классов C++, работающих вместе и реализующих некоторые общие шаблоны взаимодействия и параллелизма. В то время у создателей высокопроизводительных систем было не так много инструментов. Фреймворк ACE на C++ был самым мощным, что сделало его популярным выбором.

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

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

Что мы сделали?

Как уважающие себя инженеры-программисты, мы решили сами разработать необходимое решение. В результате создали систему на основе Berkeley DB — базы данных, которая тогда еще не была частью Oracle. Berkeley DB (часто называемая BDB), отличающаяся высокой внутрипроцессорной производительностью и поддержкой персистентности (ее основная функция заключается в хранении пар «ключ-значение»), идеально подходила для решения нашей проблемы — получать быстрый доступ к данным в процессе работы и одновременно сохранять их для обеспечения устойчивости.

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

Неполезная библиотека

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

Такой подход кажется вполне оправданным, пока дело не доходит до контекста.

Неполезная библиотека будет предполагать или навязывать свой контекст пользователям.

Как это выглядит на практике?

void sort<class T>(T& container) {
  // …
}

Рассмотрим (простой) пример функции sort в C++, просто взглянув на ее сигнатуру. Вроде бы совершенно безобидный интерфейс, приятный и лаконичный на вид. Можно спорить о том, нужно ли использовать понятия для обеспечения требований к типу (хотя обычно этот вопрос остается в тени).

Однако, если бы это была единственная функция в библиотеке, такой интерфейс навредил бы библиотеке. Почему?

Функция без необходимости делает предположения и навязывает пользователю свой контекст.

Вспомним, что обычно выполняет sort-функция:

  • сравнивает элементы (требуя слабого частичного упорядочивания);
  • меняет элементы местами.

Это определение sort-функции не позволяет пользователю предоставить свою функцию сравнения или другой способ замены элементов.

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

class DefaultCompare {
 public:
  bool operator()<T> (T&& left, T&& right) {
    return left < right;
  }
};

class DefaultSwap {
 public:
  void operator()<class T>(T&& left, T&& right) {
    using std::swap;
    swap(left, right);
  }
};

void sort<class R, class C = DefaultCompare, class S = DefaultSwap>(
  R&& range,
  C compare = C(),
  S swap = S()) {
  // …
}

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

Согласитесь, бесполезную библиотеку трудно обнаружить, если смотреть на нее с точки зрения разработчика.

Вот ключевой урок, который я усвоил в самом начале своей карьеры: чтобы создать полезную библиотеку, учитывайте прежде всего точку зрения пользователя (которым, возможно, когда-нибудь окажетесь и вы сами).

Принципы проектирования

Соблюдение некоторых принципов проектирования позволяет создавать полезные библиотеки. Вот основные из них:

  • модульность: компоненты библиотеки должны быть самодостаточными, взаимозаменяемыми и независимыми;
  • многоразовость: библиотека должна быть достаточно универсальной, чтобы стать полезной для широкого круга контекстов, но не слишком универсальной, чтобы оказаться практически непригодной для использования;
  • расширяемость: точки расширения необходимо так встроить (или спроектировать их таким образом), чтобы будущие расширения были возможны без особых проблем.

Рассмотрим подробнее каждый из этих принципов и примеры их реализации.

Модульность

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

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

В основополагающей работе Дэвида Парнаса «О критериях, которые следует использовать при декомпозиции систем на модули» ключевыми наблюдениями являются следующие: 

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

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

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

Хорошим примером может служить стандартная библиотека C++, в которой контейнеры отделены от алгоритмов, но работают синергетически друг с другом благодаря абстрактному (или обобщенному) программированию (Generic Programming).

Многоразовость

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

Лучший способ определить, является ли библиотека пригодной для повторного использования, — проанализировать количество требований, которые предъявляют к пользователю ее интерфейс и реализация.

Рассмотрим два примера контейнера, в котором может быть 0 или 1 элемент:

// Пример 1:
template <class T>
class Maybe {
  public:
    explicit Maybe(T&& value) : ...
  ...
};

// Пример 2:
class Object;
class MaybeObject {
  public:
    explicit MaybeObject(Object* object) : ...
  ...
};

В 1-м примере Maybe<T> требует только, чтобы T можно было рассматривать как значение.

Во 2-м примере MaybeObject требует, чтобы хранимое значение было объектом и чтобы оно являлось указателем на уже существующее.

Поскольку Maybe<T> может применяться к гораздо большему числу типов, чем MaybeObject, Maybe<T> считается более пригодным для повторного использования.

Расширяемость

В целом, библиотеки обычно имеют конкретное целевое назначение.

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

Примерами таких точек расширения могут быть:

  • предоставление альтернативных реализаций в общих контекстах (таких как пользовательский компаратор, пользовательский аллокатор, объект функции/обратный вызов и т. д.);
  • предоставление явных плагинов или расширений (например, промежуточных функций в конвейере обработки запросов веб-сервисов, пользовательских кодировщиков/декодировщиков, обработки ошибок и т. д.);
  • точки настройки для поддержки новых состояний или входов (например, обработка ошибок, сериализация и т. д.).

Примеры полезных библиотек

Мне попадалось несколько замечательных библиотек на разных языках программирования. Они повлияли на мое отношение к написанию библиотек или, по крайней мере, хорошо спроектированных модулей. Вот три из них:

  • Mediatr — библиотека, которая является реализацией шаблона «Mediator» («Посредник») на C#;
  • Boost.Graph — библиотека, предоставляющая продуманные интерфейсы для работы с графами (в смысле математики/компьютерных наук, а не графиков/визуализации);
  • C++ Ranges — библиотека, которая привнесла компонуемое проектирование в массы программистов на C++.

Уверен, что подобных примеров больше. Просто нет возможности упомянуть все корректно написанные библиотеки, на создание которых потребовалось немало сил.

Заключение

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

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

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

Читайте нас в Telegram, VK и Дзен


Перевод статьи Dean Michael Berris: Why it’s so hard to write good libraries

Предыдущая статьяОсвоение Scrollable во Flutter
Следующая статьяКосмическое приключение компилятора Golang