Повторное использование кода — как это бывает на практике

в 13:25, , рубрики: c++, reusable component, Анализ и проектирование систем, разработка игр, Совершенный код

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

Но когда дело доходит до этого на практике — чаще всего что-то идёт не так. Есть одна очень умная мысль на этот счёт: «Не пытайтесь делать код переиспользуемым, пока вы не видите как минимум три разных места, где его можно будет применить». Я считаю этот совет очень хорошим — я видел немало ситуаций, когда он помог (или помог бы) избежать одержимости попытками написания переиспользуемого кода там, где проблему можно было решить для одного конкретного случая «здесь и сейчас».

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

Почему бы не переиспользовать?

Аргументировать написание переиспользуемого кода легко: если мы напишем и отладим код один раз, а пользу от него получим в нескольких местах, это сразу увеличит бизнес-ценность нашего продуктапродуктов, верно?

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

Как тут не вспомнить модный нынче культурный феномен под названием «Паттерны». Паттерны изначально имели чисто описательный характер. Программисты находили тут и там какие-то общие идеи, подходы — и давали им названия. Накопив изрядное количество таких вот именованных сущностей, люди вдруг поставили телегу впереди лошади. Оказывается, теперь в программировании паттерны стали обязательными, а каждый из них должен быть реализован строго в соответствии с чётко прописанными правилами. Если вы строите некоторую систему типа Х, а на рынке уже есть три таких системы, использующие паттерн Y, то и в вашей реализации он должен быть.

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

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

Почему же мы не переиспользуем код?

Вот вам практический пример из жизни. Я хочу спроектировать систему обработки колбеков в видеодвижке игры. Но у меня уже есть несколько подобных систем, спроектированных другими разработчиками моей компании в процессе работы над предыдущими похожими проектами. Большинство из них построенны на одних и тех же принципах: у нас есть «источники событий», есть механизм подписки на события, при возникновения события нужно дёрнуть каждого подписавшегося и оповестить его о событии. Просто.

Вот только игра Guild Wars 2 имела в своём исходном коде где-то шесть различных реализаций этой простой архитектурной идеи. Одни были в клиенте, другие в сервере, третьи использовали сообщения между клиентом и сервером, но в общем все они делали одно и тоже, и реализация их принципиально тоже была одной и той же.

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

Ну хорошо, давайте не будем переделывать существующий код. Он, в конце концов, и так работает. Но давайте подумаем о будущем. Люди не прекратят играть в игры, а значит программисты не прекратят делать игры. А значит в них будут движки, а этим движкам понадобится хорошая стандартизированная библиотека колбеков, которую все полюбят с первого взгляда. Давайте её напишем? А давайте!

Мы хотим написать открытый код, чтобы другие люди могли его использовать. Он, с одной стороны, должен быть достаточно мощным, чтобы монстры типа Guild Wars 2 нашли в нём всё необходимое, но с другой стороны, он не должен включать в себя чего-то чисто специфического для одной игры (или даже платформы), поскольку мы же хотим написать ПЕРЕИСПОЛЬЗУЕМЫЙ код.

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

Некоторые из зависимостей просты и очевидны. Класс Foo наследуется от класса Bar, значит они зависимы — это понятно. Но есть и более интересные формы зависимостей. Допустим, мы всё-таки напишем и опубликуем нашу библиотеку колбеков. Где-то внутри неё библиотека должна будет иметь контейнер для хранения информации о подписчиках. Ну, тех самых, которых нужно будет уведомлять о событиях. Как ни крутись, что ни придумывай, а контейнер нужен. Как мы реализуем контейнер? Ну, мы же не в каменном веке. Да и вообще это статья о переиспользовании кода. Очевидным ответом (вне мира геймдева) будет взять контейнер из стандартной библиотеки С++. Это может быть std::vector, или std::map, или оба.

В играх по определённым причинам использование стандартной библиотеки часто запрещено. Я не буду здесь объяснять почему, почитайте об этом где-нибудь. Просто примите как факт, что иногда вы не можете выбирать используемые в проекте библиотеки.

Итак, у нас остаётся несколько вариантов. Я могу реализовать свою библиотеку с зависимостью от стандартной библиотеки С++, что сразу сделает её бесполезной для половины потенциальных пользователей. Им придётся переписать код моей библиотеки, чтобы избавиться от всего не доступного на их платформе. Объёмы потенциально переписанного кода будут такими, что говорить о каком-то «переиспользовании» кода моей библиотеки будет уже неловко.

Второй вариант — реализовать контейнер самостоятельно, внутри библиотеки. В самом деле, простые контейнеры вроде связанного списка или вектора написать не так уж трудно. Но это ведь с точки зрения переиспользования кода вариант ещё хуже — такие контейнеры есть в стандартной библиотеке, они наверняка есть в библиотеках тех пользователей, которые захотят использовать нашу библиотеку. И вот мы добавляем ещё один набор контейнерных типов! Какое уж тут переиспользование кода — мы наоборот наплодили лишних сущностей выше крыши.

Контрактное программирование

Идея контрактного программирования совсем не нова, но используется на практике она не так часто. Итак, давайте начнём с простой зависимости в виде вышеописанного контейнера:

class ThingWhatDoesCoolStuff
{
    std::vector<int> Stuff;
};

Этот код очевидно делает класс ThingWhatDoesCoolStuff зависимым от std::vector, что не удобно для тех людей, которые не могут использовать std::vector из стандартной библиотеки. Давайте сделаем код немного дружелюбнее к ним:

template <typename ContainerType>
class ThingWhatDoesCoolStuff
{
    ContainerType Stuff;
};

// клиенты теперь могут сделать так:
ThingWhatDoesCoolStuff<std::vector<int>> Thing;

Стало лучше, хотя клиентам и пришлось написать достаточно длинное и странное название типа (что, конечно, можно визуально упростить с помощью typedef или using).
Кроме того, всё сломается, как только мы начнём использовать контейнер в коде:

template <typename ContainerType>
class ThingWhatDoesCoolStuff
{
public:
    void AddStuff (int stuff)
    {
        Stuff.push_back(stuff);
    }

private:
    ContainerType Stuff;
};

Доступ к контейнеру требует наличия в нём метода push_back для добавления элементов. Всё, конечно, будет хорошо, пока нашим контейнером будет стандартный вектор. А если нет? Если в контейнерном типе, который предоставит нам пользователь, метод для добавления элемента будет называться Add? Мы получим ошибку компиляции. И пользователю придётся либо переписывать код нашей библиотеки для совместимости со своим контейнером (пока-пока, переиспользование кода), либо переписывать свой контейнер и использующий его код (на это никто никогда не пойдёт).

Но, как говорится, любую проблему можно решить, добавив достаточно количество слоёв и косвенности! Давайте сделаем это:

// это будет в переиспользуемой библиотеке
template <typename Policy>
class ThingWhatDoesCoolStuff
{
private:
    // я не прикалываюсь, это реальный синтаксис
    typedef typename Policy::template ContainerType<int> Container;

    // а вот и наш контейнер требуемого типа
    Container Stuff;

public:
    void AddStuff (int stuff)
    {
        using Adapter = Policy::ContainerAdapter<int>;
        Adapter::PushBack(&Stuff, stuff);
    }
};

// Пользователям моей библиотеки нужно всего лишь написать вот это:
struct MyPolicy
{
    // это должно указывать на нужный нам контейнер
    template <typename T> using ContainerType = std::vector<T>;

    template <typename T>
    struct ContainerAdapter
    {
        static inline void PushBack (MyPolicy::ContainerType * container, T && element)
        {
            // этот код будет разным в зависимости от типа контейнера
            container->push_back(element);
        }
    };
};

Давайте разберёмся, как всё это будет работать. Во-первых, мы определяем шаблонный класс Policy, который позволяет нам отделить бизнес-логику от её зависимостей (вроде контейнеров). Любой код, претендующий на переиспользование, должен быть внятно отделён от его зависимостей. Описанный выше способ с шаблоном не единственный вариант реализации, но один из неплохих.

Синтаксис в приведённой выше реализации действительно не блещёт лаконичностью. Всё, что мы хотим сказать этим кодом: «эй, мне нужен контейнер и вот API контейнеров, который я понимаю, дай мне подходящую реализацию и я сделаю своё дело».

Шаблон здесь используется для избежания лишних затрат на вызовы виртуальных функций. Теоретически я мог бы просто сделать базовый класс «Container», определить в нём виртуальные методы, бла-бла-бла, боже я ненавижу себя уже просто за попытку подумать о таком ужасном варианте. Давайте просто забудем об этом навсегда.

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

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

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

Заключение

Напоследок можно взглянуть на производительность приведённого примера. В отладочных сборках она может хромать, но релизные билды за счёт использования шаблонов получат хорошо оптимизированный эффективный код. С рантайм-производительностью всё будет хорошо. А что на счёт времени сборки? Шаблоны увеличивают время компиляции. Но в нашем примере шаблон будет конкретизироваться определённым типом лишь один раз, что примерно сравнивает время компиляции с версией без шаблона. Тем ни менее, при множественном применении шаблонов легко прийти к ситуации катастрофического увеличения времени компиляции — за этим нужно следить. И даже при этом я считаю такой подход лучшим вариантом, чем определение кучи связанных абстрактных интерфейсов.

Вот и всё, что я хотел рассказать о данном примере декомпозиции. Надеюсь это было полезно.

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

Автор: Infopulse_Ukraine

Источник

Поделиться

* - обязательные к заполнению поля