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

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

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

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

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


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

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

Как тут не вспомнить модный нынче культурный феномен под названием «Паттерны». Паттерны изначально имели чисто описательный характер. Программисты находили тут и там какие-то общие идеи, подходы — и давали им названия. Накопив изрядное количество таких вот именованных сущностей, люди вдруг поставили телегу впереди лошади. Оказывается, теперь в программировании паттерны стали обязательными, а каждый из них должен быть реализован строго в соответствии с чётко прописанными правилами. Если вы строите некоторую систему типа Х, а на рынке уже есть три таких системы, использующие паттерн 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», определить в нём виртуальные методы, бла-бла-бла, боже я ненавижу себя уже просто за попытку подумать о таком ужасном варианте. Давайте просто забудем об этом навсегда.

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

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

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

Заключение


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

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

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

Similar posts

Comments 16

    +1
    > ThingWhatDoesCoolStuff
    Поправьте what на that или which. Чтоб смысл был, а то спотыкаешься
      0
      Сохранено название из оригинальной статьи, очевидно.
        0
        Да. Причём автор — американец, native speaker. Почему-то он решил, что так будет правильнее назвать.
          0
          Почему-то он решил, что так будет правильнее назвать.

          Может просто ошибся?
      0

      Есть очень универсальная мысль — так-то оно так, конечно, но случись что — вот тебе и пожалуйста.


      Например, некоторый период я писал, в основном, на ActionScript, теперь пишу, в основном, на андроидной Java, ведь уже пора перейти на Kotlin — вот так из огня да в полымя, где от прошлого остается пепел.

        +3

        ИМХО ваша статья применима к конкретному языку или отрасли. Мне как-то странно видеть, когда запрещено использование стандартных библиотек и вместо этого ничего не сделано. Если кто-то по каким-то соображениям запрещает библиотеку (например она не оптимизирована под конкретную архитектуру), то будь добр предоставь свою, предоставляющую те же методы с теми же свойствами. И тогда не должно быть проблем с зависимостями.
        Как пример — всякие putchar, getchar и printf — под многие платформы имеют свои реализации, но библиотеки, что их используют, работают и компилируются с ними нормально.

          +1
          Ну чего — вот есть Boost, отличный пример такой библиотеки. Да, без слез и пол литра в исходники не глянешь, но оно реально многими используется и экономит кучу времени на разработку.
            +1
            Я недавно говорил нашему архитектору, что люблю копипастить (в тестах). Да, он хмуро посмотрел на меня, но понял, что я его подкалываю.
            Смысл в данном применении для меня в том, что можно в трех разных местах попробовать разные реализации и выбрать ту которая работает лучше всего, а уже потом уже вынести в общий код. Это же принцип эволюции — мутация, комбинация. В результате получится реализация которая впитала лучшие качества своих предшественников.
            В случае с изначальным выносом, нам придется три раза подряд чинить код пока мы не придем к оптимальной реализации. А последовательно — медленнее, чем параллельно.
            Во как! Привел довод, называется. Аж сам удивился. И ведь не поспоришь… :)
              +1
              Но на практике возникает целая куча причин не использовать подобную чужую (пусть даже открытую) библиотеку. Во-первых, в такой библиотеке абсолютно точно не окажется какого-то необходимого вам функционала и её придётся дописывать. Во-вторых, огромной преградой окажутся зависимости данной библиотеки.

              Самое главное, эта библиотека, если её до этого не использовали в тысячах проектов, стопудово будет глючить в неочевидных местах и её, если опен-сорс, придется дебажить и допиливать, а если не опен-сорс, то выпиливать из проекта и искать чем её заменить. А как изнутри выглядит, например, openssl, все помнят?

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

                Чем так плоха эта идея?
                  +1
                  В общем — не так уж и плоха. Но автор писал об этом всём в контексте геймдева, где дорога каждая микросекунда, поскольку надо выдать много fps и в хорошем качестве. Интерфейс, базовый класс и реализация приведут к тому, что в объекте будет создана таблица виртуальных функций, а каждый вызов функции приведёт к поиску в этой таблице адреса, по которому её нужно вызывать. Это всё затраты времени. Копеечные в обычном приложении, но существенные в игре. Вариант с шаблонами позволяет этого избежать.
                    –1
                    Вы уверены, что автор имел в виду именно эту причину? Если обратиться к таблице стоимости операций в тактах, то вызов виртуальной функции в С++ относится к одной из самых дешёвых и безобидных операций.
                      0
                      Даже по вашей таблице разница может достигать 4 раза. Это, мягко говоря, очень много.

                      На самом деле, виртуальные функции — это только половина беды. Основная проблема — код обрастает большим количеством крайне медленных dynamic_cast, мы теряет возможность грамотно оптимизировать код (потому что компилятор не знает, что нам прилетит в рантайме), в большинстве случаев теряем inline'овые функции (хотя final немного выправляет этот пункт) и т.д. и т.п.
                  0
                  Ответили выше. Поверим на слово, что в геймдеве действительно издержки на использование интерфейсов существенны.
                  Спасибо
                    0
                    Тут есть хороший ответ касательно использования stdlib в движке Unreal Engine
                      0
                      Спасибо. Почитаю. Но моя мысль была не о том, стоит ли использовать stdlib или не стоит.
                      Есть требование «не использовать» и в данном случае не важно, чем оно вызвано.
                      Я не понял, почему решение сделать интерфейс, позволяя пользователю самому указать какой список он будет использовать (хоть из stdlib, хоть какой самописный) плохое.
                      Но увидел ответ автора на вопрос выше: "В общем — не так уж и плоха. Но автор писал об этом всём в контексте геймдева, где дорога каждая микросекунда, поскольку надо выдать много fps и в хорошем качестве. Интерфейс, базовый класс и реализация приведут к тому, что в объекте будет создана таблица виртуальных функций, а каждый вызов функции приведёт к поиску в этой таблице адреса, по которому её нужно вызывать. Это всё затраты времени. Копеечные в обычном приложении, но существенные в игре."

                  Only users with full accounts can post comments. Log in, please.