О C++ и объектно-ориентированном программировании

Автор оригинала: Marc Costa
  • Перевод
Привет, Хабр!

Вашему вниманию предлагается статья, автор которой не одобряет сугубо объектно-ориентированного подхода при работе с языком С++. Просим вас по возможности оценить не только авторскую аргументацию, но и логику, и стиль.

В последнее время много пишут о C++ и о том, в каком направлении развивается этот язык и о том, что большая часть того, что именуется «современным C++» — просто не вариант для разработчиков игр.

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

Об объектно-ориентированном программировании (ООП) как инструменте


Хотя C++ и описывается как мультипарадигмальный язык программирования, на практике большинство программистов используют C++ сугубо как объектно-ориентированный язык (обобщенное программирование используется для «дополнения» ООП).

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

Об энтропии как тайной силе, подпитывающей разработку ПО


Мне нравится представлять решение в стиле ООП как созвездие: это группа объектов, между которыми произвольно прочерчены линии. Такое решение вполне можно рассматривать и как граф, в котором объекты являются узлами, а отношения между ними – ребрами, но мне ближе феномен группы/кластера, который передается метафорой созвездия (по сравнению с ней граф слишком абстрактен).

Но мне не нравится, каким образом составляются такие «созвездия объектов». В моем понимании, каждое такое созвездие – не более чем мгновенный снимок образа, сложившегося в голове у программиста и отражающего, как выглядит пространство решения в конкретный момент. Даже учитывая все обещания, которые даются при объектно-ориентированном проектировании по поводу расширяемости, многократного использования, инкапсуляции, т.д… будущее непредсказуемо, поэтому в каждом конкретном случае мы можем предложить решение ровно для той задачи, что стоит перед нами сейчас.

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

Я видел, как это самым разным образом проявляется в ООП-решениях:

  • В иерархии возникают новые промежуточные уровни, тогда как изначально вводить их не предполагалось.
  • Добавляются новые виртуальные функции с пустыми реализациями в большей части иерархии.
  • Один из объектов в созвездии требует большей обработки, чем планировалось, из-за чего связи между остальными объектами начинают пробуксовывать.
  • В иерархию добавляются обратные вызовы, так, что объекты одного уровня могут обмениваться информацией с объектами другого уровня, при этом не обладая явным знанием друг о друге.
  • Т.д…

Все это – примеры неправильно организованной расширяемости. Причем, исход у этого всегда один, он может наступить через несколько месяцев, а может через несколько лет. При помощи рефакторинга пытаются устранить нарушения ООП-принципов проектирования, допущенные, когда в созвездие добавлялись новые объекты, а добавлялись они по причине переформулировки самой задачи. Иногда рефакторинг помогает. На некоторое время. Энтропия неуклонна, а у программистов нет времени на рефакторинг каждого ООП-созвездия, чтобы ее побороть, поэтому любой проект исправно оказывается в одной и той же ситуации, имя которой — хаос.

В жизненном цикле любого ООП-проекта рано или поздно наступает такой момент, после которого поддерживать его невозможно. Как правило, в такой момент следует предпринять одно из двух действий:

  • Перейти к «черному ящику»: скрыть созвездие за каким-нибудь фасадом и медленно вытягивать его из остальной части кода. Система может и далее решать исходную задачу, для которой создавалась, если пока еще работает прилично, но разработка новых фич полностью останавливается, а исправление багов требует очень много времени, если вообще приводит к успеху.
  • Переписать с чистого листа: ООП-дизайн, созданный для решения исходной проблемы, уже так далек от ее текущего состояния, что никаким постепенным рефакторингом его не подстроить под актуальное решение.

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

Ситуация с переписыванием решения возвращает нас к феномену мгновенного снимка имеющегося пространства решений в конкретный момент. Итак, что же изменилось между ООП-дизайном #1 и ситуацией текущего момента? В принципе, все. Проблема изменилась, следовательно, и решение для нее требуется иное.

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

О легкости удаления кода как о принципе проектирования


В любой системе, построенной по принципу ООП, именно объектам в составе «созвездия» уделяется основное внимание. Но я считаю, что взаимосвязи между объектами важны не менее, если не более, чем сами объекты.

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

О производительности по определению


Но одно из основных соображений, по которым избегается ООП-дизайн – это производительность. Чем больше кода вам требуется запускать, тем хуже будет производительность.

Также невозможно не отметить, что ООП-фичи по определению не блещут производительностью. Я реализовал простую ООП-иерархию с интерфейсом и двумя производными классами, которые переопределяют единственный вызов чистой виртуальной функции в Compiler Explorer.

Код из этого примера либо выводит на экран “Hello, World!”, либо нет, в зависимости от количества аргументов, переданных программе. Вместо того, чтобы прямо запрограммировать все, что я сейчас описал, для решения данной задачи в коде будет использоваться один из стандартных паттернов проектирования ООП, наследование.

В данном случае наиболее бросается в глаза, какую кучу кода генерируют компиляторы, даже после оптимизации. Затем, присмотревшись, можно заметить, как затратно и при этом бесполезно такое сопровождение: когда программе передается ненулевое количество аргументов, код все равно выделяет память (вызов new), загружает адреса vtable обоих объектов, загружает адрес функции Work() для ImplB и перескакивает к ней, чтобы затем сразу же вернуться, так как делать там нечего. Наконец, вызывается delete, чтобы высвободить выделенную память.

Ни одна из этих операций совершенно не была необходимой, но процессор исправно исполнил их все.

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

Возьмем, к примеру, Unity. В рамках принятой у них в последнее время практики производительность – это корректность используется C#, объектно-ориентированный язык, поскольку этот язык уже применяется в самом движке. Однако, они остановились на подмножестве C#, причем, на таком, которое жестко не привязано к ООП, и на его основе создают конструкты, заточенные на высокую производительность.

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

О борьбе со стереотипами


В статье Анджело Песке «Переусложнение – корень всего зла» автор попадает в самую точку (см. последний раздел: People) признавая, что большинство софтверных проблем на самом деле обусловлено человеческим фактором.

Людям в команде необходимо взаимодействовать и выработать общее представление о том, какова общая цель, и каков путь для ее достижения. Если в команде возникает несогласие, например, по поводу пути к цели, то для дальнейшего продвижения необходимо выработать консенсус. Обычно это не составляет труда, если различия во мнениях невелики, но гораздо тяжелее переносится, если варианты отличаются фундаментально, скажем «ООП или не ООП».
Менять мнение непросто. Усомниться в своей точке зрения, осознать, насколько неправы вы были и скорректировать курс – тяжело и болезненно. Но куда сложнее изменить мнение кого-то другого!

Мне много доводилось беседовать с разными людьми об ООП и присущих ему проблемах и, хотя я считаю, что мне всегда удавалось объяснить, почему я думаю так, а не иначе, не думаю, что мне удалось кого-то отвратить от ООП.

Правда, за годы работы я выделил для себя три основных аргумента, из-за которых люди не готовы дать шанс другой стороне:

  • «С хорошим ООП так бы не вышло». «Это плохо спроектированное ООП». «Этот код не следует принципам ООП» и тому подобное. Слышал такие вещи, когда демонстрировал примеры ООП, заведшего во все тяжкие (как я уже говорил выше, с ООП-кодом такое происходит неизбежно). Это типичный пример логического заблуждения «Ни один истинный шотландец…».
  • «Я знаю ООП, и, если начинать с чистого листа, то больше ничем пользоваться не хочу». Это страх потерять свой «сеньорский» статус после того, как на протяжении всей карьеры пользовался принципами ООП и руководил другими людьми, от которых также требовал использовать эти принципы. Я считаю, что здесь мы имеем дело с примером «ошибки невозвратных издержек».
  • «Все знают ООП, очень удобно говорить с людьми на общем языке, обладая общими знаниями». Это логическая ошибка, называемая «аргумент к народу», то есть, если практически все программисты пользуются принципами ООП, то эта идея не может быть неподходящей.

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

Похожие публикации

Комментарии 10

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


    Ну а разве ООП не про то же самое? Я имею ввиду принципы из GRASP про взаимосвязи: Low Coupling и High Cohesion.
      +6

      Сию статью можно было бы чуть менее чем полностью заменить на известное высказывание Винни-Пуха "нужно делать так, как нужно, а как не нужно — делать не нужно!"

        +1
        Но как нужно — не скажем (ибо не знаем)
          0
          Решил переписать сортировку пузырьком на ООП. Выделил объект пузырек. обернул в объект сортировка. Дальше запутался, помогите!
          +2
          Словоблудие ни о чем. Ни примеров, ни сравнений, ни альтернатив.

          ООП — что имеется в виду — инкапсуляция, полиморфизм или все сразу? Если в Java практически невозможно использовать одно без другого, то в с++ можно написать несколько мегабайт хорошего кода без следов полиморфизма, на одной инкапсуляции. Это ООП или нет? Это плохо или хорошо?

          С++ — это мультипарадигмальный язык — прекрасно, пару примеров разных парадигм в студию.

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

          Да, но зачем? Поспорить сам с собой? Почему в коде небезопасное легаси из другого языка (stdio.h, printf)?
          Где реализация (для сравнения) в рамках идеально вылизанной, всеобъемлющей и оптимальной архитектуры без ООП? Дайте угадаю, там внутри будет тот же printf. Заходим в реализацию printf и что видим? Видим там парсер текста, копирование символов с места на место, кучу malloc и free, псевдо-ООП код, код преобразования int, long, double и других типов данных в текст. И это все загрузится в память, да. Для приведенного примера оно не нужно, нет.

          Также невозможно не отметить, что ООП-фичи по определению не блещут производительностью.

          Тезис требует подтверждения. Методика исследования и результаты в студию. Примеры брать не из головы автора, а из реальных проектов. В приведенном куске кода автор уже продемонстрировал, что ни C++, ни анализ производительности не являются его коньком.

          Следующий тезис — ООП помогает от энтропии только на время. Согласен, но что помогает от энтропии навсегда? По-моему — только rm -rf /.

          В моем понимании, каждое такое созвездие[объектов] – не более чем мгновенный снимок образа, сложившегося в голове у программиста

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

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

            А что до вашего примера, то для него и языка С много (даже без плюсов) — его можно элементарно закодить на ассемблере. Однако от языка высокого уровня вы, наверное, отказываться не собираетесь?
              0

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

              0
              А что выдается за альтернативу автор поясняет?
              Структурное программирование?
              Функциональное?
                +2
                Загляните по ссылке в заголовке поста. Блог автора (4 записи) плюс краткая информация о нём (включая фотографию) рисуют мне следующий образ. Молодой человек работает в геймдеве над игровым движком, его интересы сосредоточены вокруг перформанса. В примерах кода он использует стиль чистого C. Аргументирует в эту сторону (например, цикл по C-массиву, представленному указателем, ему милей, чем использование стандартной библиотеки C++), вплоть до отказа использования не только стандартной библиотеки, но и общепринятых C++ парадигм, например RAII. Ссылается на видео с CppCon 2014, посвящённого Data Oriented Design и чужие блоги (я планирую посмотреть и то и другое). Возможно, он предполагает, что его читатели, также как он, работают над приложениями, в которых перформанс — критичен, а безопасность получающегося кода и скорость разработки — нет. Мне его рассуждения не показались убедительными. Если кому-любо интересно, я бы продолжил после ознакомления с материалами по ссылкам из записей этого блога.

                Поверхностно ответ на «Структурное программирование? Функциональное?» — скорее ближе к структурному.
                0

                Напомнило про недавнее обсуждение ООП virtual inheritance vs DOD (ECS) из другого поста, может кому—то будет интересно взглянуть https://m.habr.com/en/company/piter/blog/524882/comments/#comment_22270190


                Заметьте, что ECS решение позволяет оптимально (не смотря в цикле все объекты типа ITransaction) фильтровать по отдельным компонентам, что в теории важно для задачи фильтрации из статьи по ссылке.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое