@safinaskar

Почему в C++ нужно строить всю программу на ООП (длинный вопрос)?

Я программист на C++. Хочу понять, почему считается хорошим тоном строить всю программу на основе ООП. Приведу свой взгляд на ООП, и хочу, чтобы вы мне объяснили, где я не прав.

Пара слов о терминологии. Под "объектом" я понимаю "объект в смысле ООП", а не то, что понимается под объектов в стандарте C++ и на cppreference.com ( en.cppreference.com/w/cpp/language/object ).

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

Да, вы можете сказать, что ООП нужен, чтобы код выглядел красивее и чище, и чтоб его за мной было проще редактировать другому программисту. Отвечу так: "объяснить я и сам могу, я понять хочу" (c). Чем отличается разбиение на классы от обычного разбиения на функции, я не понимаю. И почему в первом случае другому программисту, работающем после меня, будет якобы проще.

ООП - это три кита: инкапсуляция, наследование и полиморфизм (при этом под полиморфизмом понимается динамический полиморфизм). Из этих трёх, как мне кажется, часто возникает потребность лишь в одном: инкапсуляция. И ещё в одном, не входящем в эту троицу: RAII. Что касается наследования и полиморфизма, то в них возникает потребность очень редко.

Но даже инкапсуляция и RAII, на мой взгляд нужны лишь там, где они нужны. Скажем, владеем мы каким-то ресурсом - файловым дескриптором или ещё чем-нибудь - нужен RAII. Или есть некие данные, к которым мы хотим предоставить некий интерфейс (возможно, гарантирующий сохранение неких инвариантов этих данных) и запретить их менять напрямую - нужна инкапсуляция (то есть в C++ это будет выражено классом с private и public функциями-членами). А зачем нужно строить всю программу на основе ООП - я не понимаю.

Что касается наследования и полиморфизма, то могу привести лишь несколько примеров, где они нужны. (При этом я считаю, что наследование нужно лишь в связке с полиморфизмом.) Первый - это библиотеки виджетов, такие как Qt. Объясню, зачем там нужно наследование и полиморфизм, на мой взгляд. Есть большой виджет, скажем, окно, на котором расположены другие маленькие виджеты, скажем, кнопки, чекбоксы и пр. Большой виджет хранит список маленьких виджетов. Допустим, большому виджету понадобилось нарисовать себя. Для этого нужно нарисовать все маленькие виджеты. А для этого он итерируется по ним по всем и вызывает у каждого функцию-член, скажем, draw. Проблема в том, что каждый из маленьких виджетов рисуется по-разному, то есть функция draw должна быть у каждого своя. Но при этом большой виджет должен неким образом одним циклом проитерироваться по всем и вот эту вот эту функцию draw, которая у каждого своя, каким-то образом вызвать. Как это сделать? Нужно сделать базовый класс и назвать его, скажем, QWidget (так он назван в Qt), объявить у него виртуальную функцию draw, унаследовать от QWidget остальные виджеты и переопределить у них этот draw. Большой виджет будет хранить в себе список QWidget'ов по указателю. Будет итерироваться по ним и virtual call'ом вызывать draw. Готово.

Вот только подобная ситуация в обычных программах (за пределами библиотек виджетов) возникает крайне редко. В данном случае наследование и полиморфизм оказались нужны, потому что мы имеем в данном месте указатель на "виджет вообще", мы не знаем в данном месте кода, какой это именно виджет, но нам нужно вызвать draw, который может быть разным в зависимости от типа виджета. Но подобное возникает крайне редко в программах за пределами библиотек виджетов. Где вообще может такое оказаться, что у нас есть указатель на сущность, и мы не знаем, что это конкретно такое?
Ладно, хорошо, такое может случится, но лишь иногда.

Ещё один пример, который я могу придумать: хранение AST (abstract syntax tree). Там, допустим, узлы, хранящие выражения, представлены типом Expression, от которого наследуются разные конкретные выражения, скажем, FunctionCall, Addition, Multiplication и так далее. И у Expression есть виртуальные функции для различных манипуляций с этим узлом. Но и это, опять-таки, лишь какой-то очень частный случай. И даже в нём можно придраться. Скажем, сделать выражение просто std::variant, хранящим разные варианты этого выражения. Тогда никакое наследование и полиморфизм не нужны. Когда нужно сделать какие-то манипуляции с выражением, делаем switch по типу выражения. Вы можете возразить, что тогда при добавлении нового типа выражения придётся всё менять. Ну да. В случае ООП тоже пришлось бы много чего менять. Вы скажете, что при добавлении нового типа выражения придётся всё перекомпилировать. Ну да. У типичных языков программирования типа C++ новые типы выражений добавляются крайне редко, ну придётся лишний раз каждый год пересобирать компилятор, ничего не случится. Вы скажете, что virtual call быстрее switch. Что ж, может быть оно и так, но тогда получается что в данном конкретном случае преимущество ООП всего лишь в скорости. Никаких философских обоснований тут нет.

Возьмём STL. STL'ные классы не имеют наследования и полиморфизма. Точнее, vector часто наследуется от vector_base, но это лишь деталь реализации, не видная пользователю vector'а. Да и само это наследование нельзя назвать идеоматическим, т. к. идеоматическое наследование, как я понимаю, подразумевает отношение "является", т. е. выходит, что "vector является vector_base", что за бред? От vector'а не рекомендуется наследоваться. Зато у STL'ных классов есть инкапсуляция и RAII, как раз то, что я хорошо понимаю. Я действительно понимаю, зачем vector'у инкапсуляция и RAII. Но вот без наследования и полиморфизма весь этот STL прекрасно работает, что, на мой взгляд, доказывает их ненужность.

На одном собеседовании меня попросили рассказать, как бы я стал писать тетрис. Как я бы разбил это приложение на классы. Подразумевается, что это графическое приложение. Фигурки цветные, красивые. Есть счётчик очков, всё как полагается. И я не смог ответить. Я не вижу тут необходимости в классах. Той редкой ситуации, как было с теми виджетами, когда у нас есть указатель непонятно на что, тут нет. Так что наследование и полиморфизм тут не нужны. Некие данные, у которых должен сохраняться некий инвариант, к которым хочется предоставить некий интерфейс, тут тоже отсутствуют. Значит, инкапсуляция тоже не нужна. Всё, не нужны тут объекты. Нужны функции. Нужны struct'ы. Мне подсказывают, что у приложения есть главное меню, в нём можно выбрать пункт "играть" и перейти к основному игровому полю. Ну да, окей, и что? Это всего лишь значит, что при нажатии на "играть" должна вызываться функция, которая будет отвечать за сам игровой процесс. А когда игра будет окончена, эта функция должна сделать return и вернуть управление в функцию, отвечающую за меню. Где тут объекты? Опять не нужны.

Мне нравится код ядра Linux. Никакого разделения программы на объекты там нет, и всё там окей, прекрасно без такого разделения живут. Правда, полиморфизм там всё же есть в каком-то виде. Например, там есть struct file (которая отвечает за открытый файл), который хранит в себе указатель на struct file_operations (я смотрел исходники Linux 4.11.6). struct file_operations - это своего рода таблица виртуальных функций. И она хранит в себе указатели на операции, которые можно делать с файлами. Своего рода виртуальные функции. И они действительно нужны. Потому что в ядре есть места, где мы работаем с файлом, и не знаем, что это именно за файл (это может быть файл на диске, конец пайпа или ещё что-нибудь), но в этот файл хочется записать. Как в том примере с виджетами. Видите? Полиморфизм есть, но он есть только там, где это нужно. И никакого "А давайте всю программу разобьём на классы, просто потому что так надо" тут нет. На мой взгляд, Linux устроен совершенно верно, именно так и нужно писать.

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

Вы можете ответить "Да, ты прав, ООП не нужно, точнее, нужно, но лишь иногда, писать всю программу на классах не надо". И может, даже аргументы какие-нибудь привести. Так вот, это не то, что мне сейчас надо. Я сейчас ищу работу. И для этого я хочу понять ООПшный стиль написания программ. Пусть даже он неправильный, скажем, с академической точки зрения. Но я хочу действительно его понять. Я хочу понять, как оно там сейчас делается в бизнесе. Это то, что мне сейчас на работе пригодится. Так что прошу писать только аргументы в пользу ООП, но не против. Я не хочу тут устраивать холивар на тему "Нужен ли ООП". Холивар на тему "А какое правильное определение ООП?" тоже прошу не устраивать. Ну там, "Что понимал под ООП Alan Kay?" и прочее. Будем понимать ООП в том смысле, в котором его обычно понимаю программисты на C++ сегодня, пусть даже по каким-то причинам это определение кому-то покажется неправильным.

Окей, что я хочу в качестве ответа. Идеальной была бы ссылка на какую-нибудь статью в блоге. Где бы объяснялось, зачем нужно делить программу на объекты. С примерами. Скажем, разбиралась бы какая-нибудь типичная задача. Тетрис, текстовый редактор. И объяснялось бы, как поделить её на классы. Причём, чтобы в качестве классов были не только виджеты. Зачем виджеты сделаны классами, допустим, я уже понял. И главное, чтобы объяснялось почему программу нужно написать именно так, на ООП, а не просто функциями, и почему именно таким способом разбить на классы, а не другим. Просто объяснения в стиле "Давайте напишем на ООП. Хряк, хряк, хряк. Получилось" не надо, хочется понять, зачем было сделано на ООП.

Может быть, можете посоветовать книжек. Но, опять-таки, тех, где на примерах объяснялось, зачем нужен ООП.
  • Вопрос задан
  • 1863 просмотра
Пригласить эксперта
Ответы на вопрос 7
@Mercury13
Программист на «си с крестами» и не только
Задача ООП: 1) Локализовать изменения состояния объекта (инкапсуляция); 2) связывать разные кирпичики данных через стандартные интерфейсы (полиморфизм).

Простейший тетрис не слишком велик, чтобы его писать на чистом ООП.
Но представьте себе, мы начинаем налаживать настраиваемое управление джойстиком или клавиатурой. И тогда у нас появляется такой код.
enum {
  BT_LEFT = 1,
  BT_RIGHT = 2,
  BT_ROTATE = 4,
  BT_SOFTDROP = 8,
  BT_HARDDROP = 16,
  BT_PAUSE = 32,
  BT_CONNECTED = 32768,   // бит, указывающий, что контроллер подключён
};
class Controller {  // интерфейс
public:
  virtual unsigned poll() const = 0;   // сочетание битов BT_XXX
  virtual ~Controller = default;
};

Классы Keyboard и Joystick поддерживают интерфейс Controller, и подмена клавиатуры на джойстик и наоборот ничего не изменит.
Вот вам полиморфизм.

Текстовый редактор превращаем в многооконный — берём класс Editor и пристраиваем его не к программе в целом, а к MDI-окошку. Вот вам инкапсуляция — локализованное изменение состояния.

Я как-то мучил движок Doom. Он написан в самом настоящем объектном стиле на чистом Си! Хотя и там были проблемы: сетевой код был куда хуже по качеству, чем сам движок. Писали разделённый экран, глобальную переменную netgame разделили на две, multiplayer и netgame и долго-долго правили баги, где multiplayer, где netgame (было дело, участник десматча ввёл IDKFA, это сработало и вызвало рассинхронизацию). А код пользовательского интерфейса — вообще медвежуть!
Ответ написан
Комментировать
TrueBers
@TrueBers
Гуглю за еду
Длинному вопросу — длинный ответ:

при этом под полиморфизмом понимается динамический полиморфизм

А почему вы отрицаете статический? У динамического, например, есть накладные расходы на vtable, инвалидации кешей, и т. п. Если бы STL использовал динамический полиморфизм, у нас бы не было такой эффективности. А у статического таких проблем нет.

инкапсуляция (то есть в C++ это будет выражено классом с private и public функциями-членами)

Инкапсуляция — это совсем не про private и public. А скорее про интерфейс и его сокрытую реализацию.
www.ddj.com/cpp/184401197

Про отрисовку виджетов, на самом деле, вопрос холиварный. С виджетами изначально мы имеем поломанный интерфейс. Т. е. Widget может добавить в себя тип Widget* или Widget&. А теперь делаем так:
class Widget {
public:
  void add(shared_ptr<Widget> child);
};

auto root = make_shared<Widget>();
root->add(root); // о_О шта?

Мы так сделать можем, а значит интерфейс неверный. Кучи проверок в функции add() никому не нужны. Есть такой мужик Sean Parent, может слышали, вот он предложил этой проблеме замену на другую структуру данных со статическим полиморфизмом, без ненужных оверхедов. Они это используют в Фотошопе. На каком ещё языке это можно сделать столь эффективно?

мы не знаем в данном месте кода, какой это именно виджет, но нам нужно вызвать draw, который может быть разным в зависимости от типа виджета

А как же мы у STL-контейнеров тогда это вызываем и всё прекрасно работает без наследования и виртуальных функций?

Вы скажете, что virtual call быстрее switch

Не факт, зависит от оптимизатора и количества функций. Адекватный оптимизатор разворачивает свитч в jump-table, который на пару процентов может быть быстрее, чем виртуальный вызов. При этом, точно так же, он может девиртуализировать виртуальную функцию если она используется в элементарном базовом блоке, например, в цикле.

От vector'а не рекомендуется наследоваться

От него и никогда не требуется этого. Достаточно набросать адаптер, идеология C++ в трёх, как вы сказали, китах: контейнеры, итераторы и алгоритмы. Написав адаптер, он будет работать со всей стандартной библиотекой и алгоритмами, чего обычно более, чем достаточно. Например, те же std::queue, std::priority_queue и std::stack являются адаптерами и могут изменять вектор (и не только), ни от кого при этом не наследуясь.

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

Ну, дык, верно. Вас собеседуют же не всегда гении. Бывают дурни, задроты, идеалисты, холиварщики, ЧВСшники. Ничего хорошего от них обычно не стоит ждать. От таких надо валить подальше просто.

Насчёт виртуальных функций в Си и прочих притаскиваний функций плюсов в этот язык. Считаю, что это дурость, когда можно просто создать coding convention для плюсового кода, и писать на нём в стиле Си, но при этом юзать множество полезных плюсовых фич. Это верный подход, имхо. А когда начинается имитация этого всего: забыл инициализировать, или где-то, непойми где, инициализировал не тем, пока найдёшь проблему, вместо того, чтоб просто открыть код конструктора.

Итого, мой ответ:
1. Своей задаче — свой инструмент. Не забивайте гвозди микроскопом, он вам пригодится целым для другого.
2. Не холиварьте. Если так "положено", но нихрена ни в какие рамки не лезет, не нужно это дальше туда пихать. Нужно переступить через гордость и идеалы, и сделать по-другому.
3. Выжимайте максимум из каждой парадигмы и технологии. Благо, в C++ этого навалом. Где-то функциональщина больше попрёт, где-то лучше шаблонами нагородить. А уж где не получилось, придётся костыли пихать.
Ответ написан
gbg
@gbg Куратор тега C++
Любые ответы на любые вопросы
У вас ошибка в первом предложении. С++ - мультипарадигменный язык. Программа на нем строится так, как будет выгоднее с точки зрения архитектуры.

А STL - это отличный пример ортогонализации контейнеров, данных и алгоритмов.
Ответ написан
jcmvbkbc
@jcmvbkbc
"I'm here to consult you" © Dogbert
Мне нравится код ядра Linux. Никакого разделения программы на объекты там нет, и всё там окей, прекрасно без такого разделения живут.

Да-да-да, https://lwn.net/talks/fosdem-kobject/

Полиморфизм есть, но он есть только там, где это нужно. И никакого "А давайте всю программу разобьём на классы, просто потому что так надо" тут нет. На мой взгляд, Linux устроен совершенно верно, именно так и нужно писать.

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

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

Сюда идеально подходит книга "Паттерны проектирования". (скан, кстати, не очень, пропущен важный кусок истории в конце). Только не надо читать про сами паттерны. Прочитай всё остальное: откуда они взялись, какова их история, как ими пользоваться.
Ответ написан
Adamos
@Adamos
Право, может быть, вы даже "Идеальный код" не читали?
ООП - это уникальная возможность разделить код на уровни абстракции. Реализовать все скучное, но необходимое, внутри методов класса - и забыть о нем все, кроме его интерфейса. В результате программа пишется на более высоком уровне и читается на нем же. Детали требуется изучать только тогда, когда это действительно требуется.
Километровые простыни функций, которые может вызвать кто попало где попало с неизвестно как подготовленными входными данными и столь же неизвестными ожиданиями на выходе - ну, это может нравиться... если у вас много свободного времени или вы полжизни изучали именно этот хаос.
Ответ написан
Комментировать
wertex
@wertex
Разработка автомобильного ПО
Что касается наследования и полиморфизма, то в них возникает потребность очень редко


Вот только прочитав это можно понять, что всей сути ООП вы и не поняли. У меня есть один проект (60Кб в собранной прошивке) для железа под ARM процессор на C. Проект так вырос, что подумываю в будещем реализовать это же самое на C++ с использованием ООП.
Ответ написан
Комментировать
@Vjatcheslav3345
ООП обычно предназначен для очень, очень больших и сложных программ.
Чтобы понять, зачем и когда их разработчики используют ООП можно провести сравнительный анализ сложных объектных и необъектных программ - например, сравнить линукс с singularity (singularity.codeplex.com/SourceControl/latest#veri... а код Дума - с современным открытым движком и т. д.
Ответ написан
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Похожие вопросы