Последнее время мне приходится много ревьювить, анализировать, рефакторить C# код. Практика показывает, что принципы Объектно-Ориентированного Программирования не просто вызывают затруднения в понимании, и в применении, в большинстве случаев разработчики просто избегают их использования на практике. Я очень надеюсь, что мой относительно простой пример, который можно не только скомпилировать и выполнить, но и написать свой класс расширения и снова скомпилировать, и оценить результат уже своего труда внутри небольшой законченной программы, надеюсь это поможет кому-то преодолеть барьер к использованию принципов ООП на практике.
Конечно, мне придется продемонстрировать применение на практике принципов ООП: инкапсуляция, наследование, полиморфизм и то, как они работают.
Конечно, разберем как все это влияет на производительность. Я надеюсь, что получится сформулировать некое подобие ответа на критику постулатов «чистого» кода от достаточно успешного (видимо) иностранного программиста в переводе на Хабре: «Чистый» код, ужасная производительность.
Правила «чистого» кода, изложенные в той статье помогут нам подойти к реализации принципов ООП в нашей задаче.
Мне довелось лицезреть множество абстрактных оторванных от жизни примеров с кошками, собачками, машинками, трансформерами, треугольниками, кружочками, … которые призваны объяснить принципы ООП. Ну не серьезно как-то это все. Все такие примеры приводят к тому, мне кажется, что начинающие программисты, которые уже решают реальные практические задачи, так и относятся к этой концепции, как к какой-то красивой, но абстрактной идее, которую никто не смог применить на практике. Давайте попробуем разобрать задачу, которую не только интересно порешать, но также интересно поработать с результатом ее решения, или даже попросить кого-то поработать с этим результатом чтобы оценить то, что называется “user experience” со стороны.
Формулировка задачи и того, где она может быть полезна
Сразу хочу обратить внимание: пример, который я собираюсь рассмотреть, никак не будет связан с функциональностью баз данных, хранением, доступом к данным. Я предлагаю сосредоточиться только на логике генерации арифметических выражений, проверке их решения пользователем и взаимодействии с пользователем программы при такой проверке. Для максимальной простоты и компактности примера мы будем использовать консольный ввод вывод. Но если кто-то захочет использовать идею и продавать соответствующую визуальную версию программы для Андроида например, буду благодарен за любые отчисления с продукта, Шутка :) .
Мы будем рассматривать нашу программа-задачу как будто существует некоторое задание на разработку этой программы непрофессионально сформулированное, но вполне понятное (как это обычно и происходит на практике).
Итак, мы должны написать прототип программы, которая будет проверять способность к устным вычислениям школьника, хотя, кто может гарантировать что в ходе какого-нибудь исследования (про него см. далее) не появится интереса собрать статистику по взрослым? А значит нельзя исключать расширения списка операций в любую сторону.
программа должна случайным образом выбирать арифметическую операцию, из списка, например: умножение, деление, сложение-вычитание двухзначных чисел, … (возможно даже составные примеры, примеры со скобками)
для этого должны случайным образом генерироваться числа для формирования операции для пользователя;
программа должна выводить такой случайный пример для решения-вычисления и ввода ответа пользователем;
программа должна ждать ответа, или команды на завершение программы, если решили зациклить программу (пускай по символу "q", например);
похвалить в случае правильного ответа или сообщить правильный результат в случае ошибки.
Можно вывести некоторую статистику по завершении программы.
Я даже рискну изложить здесь описание некоторого практического контекста, в рамках которого, гипотетически, могла бы использоваться программа-задача, которую мы будем рассматривать. Возможно, это поможет мне сократить количество критики относительно практической ценности такой обучающей задачи. Хотя не приходится, конечно, надеяться, что критики совсем не будет. Критика в любом случае приветствуется, это не только мое отношение, это политика Хабра, насколько я понимаю.
Представьте, что некоторая образовательная структура собирается провести исследование статистики по способностям школьников младших классов к устному счету. Требуется написать программу, которая случайным образом генерирует простые (и не очень) арифметические примеры, фиксирует-регистрирует ответы и, в каком-то виде, сохраняет их для дальнейшего анализа. Формат исследования предполагает, что параметры тестовых заданий и их содержание могут изменяться, варьироваться, дополняться на разных этапах … и т. д. ... и т. п.
Возможно, кому-то покажется что требования слишком общие и/или плохо сформулированы, но заказчик и не должен хорошо разбираться в том, для чего он пригласил нас как специалистов, наша задача состоит, в том числе, в том, чтобы продемонстрировать заказчику возможности программы, которую мы собираемся ему предложить, чтобы удовлетворить даже те его потребности, о которых он может иметь даже ошибочное представление.
Вообще, надо заметить, что задача интересна в том числе тем, что ее можно практически бесконечно уточнять и в то же время расширять для покрытия все новых и новых требований, например на способы контроля заданных уровней вероятности разного типа операций, последовательности выпадающих чисел, регистрации разного рода аспектов процесса, статистик и т. д. и т. п.
Я постараюсь, насколько это возможно, не отвлекаться на то, что можно добавить в такую программу, я постараюсь сосредоточиться на демонстрации применения принципов ООП при минимальном количестве кода и на оценке производительности полученного решения с реализованными принципами ООП.
Первая версия программы
Рассмотрим для начала код с реализацией пары операций ("*" умножить и "-" минус):
static void Main(string[] args)
{//Main1.5
const string spliter = "--------------------------------------------------------";
Console.WriteLine("Hello, colleague!");
Console.WriteLine(spliter);
Random rnd = new Random();
int[] operStat = { 0, 0 };
while (true)
{
int oIndx = rnd.Next(2);
int res;
int num1;
int num2;
if (oIndx == 0)
{
num1 = rnd.Next(2, 10);//it is one digit multiplication
num2 = rnd.Next(2, 10);//const 10 is linked to mul where we should define it?
Console.WriteLine($"Please Solve: {num1} * {num2} = <..>");
res = num1 * num2;
}
else
{
num1 = rnd.Next(2, 100);//it is two digit subtraction
num2 = rnd.Next(2, 100);//const 100 is linked to sub where we should define it?
if(num1 > num2) { res = num2; num2 = num1; num1 = res; }
Console.WriteLine($"Please Solve: {num2} - {num1} = <..>");
res = num2 - num1;
}
operStat[oIndx]++;
Console.Write("type answer here:");
string answer = Console.ReadLine();
if (answer == "stop")
{
break;
}
int.TryParse(answer, out num1);
if (num1 == res)
{
Console.WriteLine("Congratulations it is correct answer!");
}
else
{
Console.WriteLine($"{num1} is wrong answer. Correct is {res}");
}
Console.WriteLine(spliter);
}
Console.WriteLine(spliter);
Console.WriteLine($"Count of * operations={operStat[0]}");
Console.WriteLine($"Count of + operations={operStat[1]}");
Console.WriteLine(spliter);
}
Если ее скомпилировать и запустить на исполнение, можно получить такой результат, просто нажимая Enter несколько раз (про удобство при тестировании всегда надо помнить), что интерпретируется как неправильный ответ:

В общем и целом, все работает для двух операций, но как нам добавить третью и четвертую? А как расширять статистику?.. Это пара самых простых вопросов, которые приходят на ум с первого взгляда. И тут нам придут на помощь правила «чистого» кода, взятые из статьи с критикой этого самого «чистого» кода:
Используйте полиморфизм вместо «if/else» и «switch»;
Код не должен знать о внутренностях объекта, с которыми он работает;
Функции должны быть короткими;
Функции должны выполнять одну задачу;
«DRY» — Don't Repeat Yourself (не повторяйся)
Только тут есть одна загвоздка, эти правила ничего не говорят о том, что мы должны выбрать в качестве объекта, для которого мы будем реализовывать принципы ООП: полиморфизм, наследование, инкапсуляция.
Выбор объекта базового класса, философское отступление
Дело в том, что одна из самых значимых сложностей при реализации принципов ООП как раз состоит в том, чтобы правильно идентифицировать объект (класс объектов), для которых и надо применить эти принципы. Проблема состоит в том, что объекты в программе обычно совсем не похожи на объекты из привычного нам материального мира.
Для нашей задачи я предлагаю выбрать в качестве объекта математическую операцию, к классу которой относятся умножение и вычитание, которые мы использовали выше. С этого момента мы начинаем оперировать термином «абстракция» в виде обобщенной математической операции по отношению к конкретной операции, такой как «умножение», например.
Наш объект в виде математической операции в каком-то смысле является идеальным примером абстракции или абсолютной абстракцией. Математическая операция не существует как материальный объект, не принадлежит миру реальных вещей. Математическая операция — это идея о том, что можно делать с числами. Любая программа — это реализация мира идей. Это мир, в котором идеи обретают форму и наполнение, идеи становятся-превращаются в видимый, а значит осязаемый нашими органами чувств ИСХОДНЫЙ КОД. Исходный код позволяет достать нам свои идеи из головы и заставить их работать в реальном мире, позволяет нам передать идеи другим людям, через GIT, например, чтобы они их пощупали, потрогали, приспособили, присобачили куда-то, эти идеи.
Версия программы с классами
Теперь, когда мы определились с нашим абстрактным объектом, можно приступить к чистке кода. В первую очередь нам надо избавиться от if/else. Собственно этот if/else нам как бы и намекает: «Вы выбираете тут, по сути, тип объекта через индекс операции (oIndx). Может вам все-таки создать объект для этой операции, и обращаться к объекту этой операции через абстрактный интерфейс?». Если мы так и сделаем, может получиться что-то в таком роде:
static void Main(string[] args)
{//Main2
const string spliter = "--------------------------------------------------------";
Console.WriteLine("Hello, colleague!");
Console.WriteLine("To stop the program type \"q\" in answer.\n");
Console.WriteLine(spliter);
Random rnd = new Random();
MathOperation[] operArr = new MathOperation[]
{
new MulOperation(),
new DivOperation(),
new Sub2PosOperation()
};
while (true)
{
int oIndx = rnd.Next(operArr.Length); //(ocnt ++) % operArr.Length;
MathOperation proc = operArr[oIndx];
proc.init(rnd);
Console.WriteLine($"Please Solve: {proc.Visualise()} = <..>");
proc.execute();
Console.Write("type answer here:");
string? answer = Console.ReadLine();
if (answer == "q")
{
break;
}
int.TryParse(answer, out int res);
if (proc.check(res))
{
Console.WriteLine("Congratulations it is correct answer!");
}
else
{
Console.WriteLine($"\"{answer}\" is wrong answer. Correct is {proc.GetResult()}");
}
Console.WriteLine(spliter);
}
Console.WriteLine(spliter);
foreach (var op in operArr)
{
Console.WriteLine($"Count of {op.View} operations={op.Count}");
}
Console.WriteLine(spliter);
}
Теперь у нас определены три операции. Теперь мы можем добавлять операции просто добавив создание объекта нового класса, предварительно написанного класса:
new MulOperation(),
new DivOperation(),
new Sub2PosOperation()
Далее мы случайным образом выбираем одну из них и работаем мы с любой из них через интерфейс их базового класса: MathOperation.
С точки зрения интерфейса базового класса они не различаются, в этом суть полиморфизма. Код в цикле не знает с какой конкретной операцией он работает. Раз у нас есть код, который не знает внутреннего устройства объектов, с которыми он работает, это значит мы применили принцип инкапсуляции в этом коде, конкретные реализации скрыты. С наследованием все совсем просто, все это не работает если нет базового класса, от которого и унаследованы все конкретные операции.
Я только хочу здесь отметить, что все три понятия тесно связаны, наследование определяет подобие на основе базового класса и значит определяет полиморфизм. Переменные и код определенные наследником всегда можно скрыть от кода, который работает с объектом только через интерфейс базового класса.
Вот такой базовый класс у меня получился, что называется на вскидку:
abstract class MathOperation
{
public readonly string View;
protected int op1;
protected int op2;
protected int result;
public int Count;
protected MathOperation(string view) { View = view; }
public virtual void init(Random rnd)
{
op1 = rnd.Next(2, 10);
op2 = rnd.Next(2, 10);
}
public abstract string Visualise();
public abstract void execute();
public bool check(int answer)
{ Count++; return answer == result; }
public int GetResult() => result;
}
И пара классов для операций умножения и вычитания:
class MulOperation : MathOperation
{
public MulOperation() : base("*") { }
public override string Visualise() => $"{op1} {View} {op2}";
public override void execute() => result = op1 * op2;
}
class Sub2PosOperation : MathOperation
{
public Sub2PosOperation() : base("-") { }
public override void init(Random rnd)
{
op1 = rnd.Next(2, 100);
op2 = rnd.Next(2, 100);
if (op2 > op1)
{ int tmp = op2; op2 = op1; op1 = tmp; }
}
public override string Visualise() => $"{op1} {View} {op2}";
public override void execute() => result = op1 - op2;
}
Я уверен, что большинство знающих язык С# смогут написать лучше, чем у меня тут получилось, было бы кому тестировать.
Заметьте, операции отличаются не только самим действием операции, они также могут отличаться разрядностью чисел участвующих в операции. Для деления генерируются цифры от 2 до 9, но для вычитания это было бы уж слишком тривиально, поэтому выбран диапазон от 2 до 99.
Вообще говоря, можно написать классы, которые поддерживают составные операции. Например, такие:
2 + 3 * 7 = <..> или (2 + 3) * 7 = <..>
Возможно это будет темой следующей статьи, а пока рекомендую почитать «Erich Gamma, Richard Helm, Ralph Johnson, John M. Vlissides-Design Patterns_ Elements of Reusable Object-Oriented Software -Addison-Wesley Professional (1994)»
Design Pattern Catalog => Structural Patterns => COMPOSITE
В качестве подготовки. Они там графические объекты рассматривают для примера, поэтому там достаточно сложно, у нас должно получиться попроще в консольном приложении.
Да, кстати, для операции деления код писать не буду, она почему-то очень не понравилась аудитории моей предыдущей статьи про эту операцию.
Если скопировать все это в класс Program консольного C# проекта, все должно заработать, обычно после исправления пары простых опечаток, которые каким-то волшебным образом всегда остаются не замеченными.

Повторяя за статьей оппонента, можно подвести итог:
Как и требовалось по правилам, мы используем полиморфизм. Наши функции выполняют только одну задачу (в основном, но это легко довести до конца). Они короткие. Все эти хорошие штучки. В итоге у нас получается «чистая» иерархия классов, где каждый дочерний класс знает, как вычислить свою площадь, и хранит данные, необходимые для вычисления площади выполнять разные функции, привязанные к представлению этого конкретного объекта как обезличенного объекта базового класса.
Теперь попробуем разобраться на сколько лет назад нас отбросило применение ООП, да еще и вместе с C# вместо С++ по производительности.
Вопросы производительности и анекдот в тему
Даже если бы мы писали ту же самую программу на С++ мы не смогли бы применить изложенные в статье оппонента методы измерения производительности, минимум по трем причинам:
у нас нет точной последовательности обращения к объектам, с которыми происходит работа;
программа зависит от ввода данных человеком;
программа использует функции, предоставленные системой (функции консоли).
Даже если исходить из того, что вызовы виртуальных функций занимают больше времени чем заменяющие их переходы в switch/IF, а это правда хотя разница микроскопическая и зависит от контекста в общем случае. Но исследование из статьи оппонента в этом смысле нас не обманывает. Только в нашем случае это увеличение времени локальных кусочков кода в главном цикле программы будет ничтожно мало по сравнению с теми задержками, которые формируют обращения к консоли. Короче говоря, никаких проблем с производительностью для ООП реализации в этой задаче вы не сможете обнаружить.
В какой-то степени десятикратный результат снижения производительности в задаче оппонента можно назвать подтасовкой так как:
А. задача была специально подобрана, по сути, сконструирована, чтобы получить тот результат, который и был получен
Б. была использована аккумуляция-накопление по нескольким аспектам, которые в других задачах никогда не суммируются. Обычно эффекты, связанные с вызовом виртуальных функций сильно размазаны по алгоритму, поэтому ни на что не влияют, а в статье оппонента их как будто специально саккумулировали.
В. Единственная вычислительная функция, которая подвергается анализу, рассматривается в полной изоляции от какого бы то ни было использования результатов этой функции, вряд ли такое возможно на практике. Контекст, в котором используются ООП решения, зачастую играет решающую роль для оценки эффективности такого решения, в том числе с точки зрения производительности.
Таким образом то что в нашей задаче не будет проблем с производительностью это не случайность, это нормальная ситуация.
А вот задача оппонента, если кто-то сможет найти ей практическое применение хотя бы гипотетическое, похоже, действительно требует использования подхода со структурами данных (классов без методов) вместо классов в стиле ООП.
По поводу того надо ли бояться ужасной производительности у меня есть подходящий, как мне кажется, анекдот собственного сочинения:
Очередная подруга Джеймса Бонда любуясь на себя в зеркало после сумасшедшей ночи спрашивает:
"Я страшная?"
На что Джеймс Бонд невозмутимо отвечает:
"Ты же знаешь, дорогая, я ничего не боюсь. Я бесстрашный."
Заключение
Ну вот, я, кажется, написал обо всем что хотел или написал достаточно чтобы утомиться – трудно отделить одно от другого. Всегда остается чувство, что о чем-то забыл или что-то не до формулировал. Возможно, это то чувство из-за которого многие писатели становятся неврастениками. Я вроде пока держусь :). В связи с этим хотелось бы обратиться с призывом к читателю: помните, если вам чего-то в статье не хватает, возможно это признак того, что статья помогла вам шире открыть глаза. И не надо стесняться и маскировать свою точку зрения под недовольство способом изложения материала.
С уважением,
Автор
Материалы
Этот ответ на коментарий кажется мне связанным не только с темой ООП, но и с вопросами производительности решений на базе ООП, поэтому добавлю его здесь.
Третий ответ для @SpiderEkb я добавляю в начале так как он демонстрирует решение проблемы с делением и кое-что еще интересное, я надеюсь.
Это удивительно, что вы @SpiderEkb смогли наглядно продемонстрировать ловушку, в которую можно попасть совершенно на ровном месте, что называется!
a = a / b; // a = 25 / 7 = 3 в целочисленной арифметике
a = a * b; // a = 3 * 7 = 21
Если А и Б (я буду писать большими и по-русски, так нагляднее и удобнее)
у нас случайные равновероятные значения, то оказывается, что вероятность получить:
25 / 7
Будет точно такой же, как и вероятность получить:
7 / 25
То есть вероятность того, что А будет меньше, чем Б будет равна 50 процентов!
А это значит, что с вероятностью 50%, то есть в каждом втором примере мы будем предлагать поделить НОЛЬ на какое-то число. И это явно будет не то, что ожидается от программы. Это ошибка!
Парадоксально, но решение гораздо проще чем кажется, и вы уже написали это решение! Этот парадокс я попытался описать в своей предыдущей статье, видимо не очень удачно :) вы можете оценить. Но мне очень интересно было узнать, что у этого приема даже есть название: «методика проектирования «от противного», или проектирование методом абсурдной перестановки».
А вы написали это ПРОСТОЕ решение во второй строке! Ведь вполне достаточно вычислять А как произведение А * Б, то есть вот этот код (эта строчка):
a = a * b; // a = 3 * 7 = 21
будет работать, только надо чтобы А и Б генерировались в диапазоне от 2 до 9 (а не от 2 до 99!). Как бы, действует закон сохранения суммарной сложности: здесь у нас получилось проще, но за это мы должны параметр передавать в функцию генерации чтобы менять диапазон генерируемых чисел для разных операций. Складывать нужно двухразрядные числа, а для умножения и деления нужны одноразрядные числа (цифры), но мы можем теперь сделать что угодно с разрядностью, если мы реализовали возможность управлять этой разрядностью.
Если вам интересно у меня получился вот такой класс для деления:
class DivOperation : MathOperation
{
public DivOperation() : base("/") { }
public override string Visualise() => $"{op1 * op2} {View} {op1}";
public override void execute() => result = op2;
}
Как видите, мне само деление вообще не понадобилось, в этом смысле производительность тут идеальная :) !
Ответ №1 для @SpiderEkb, хотя это не совсем ответ, а что-то вроде анализа по приведенным вами в комментариях фрагментам кода.
У меня недостаточно информации, конечно, но даже на ее основе я могу построить предположение-пример, как и обещал. Рассматривайте этот пример как способ анализа (один из возможных), в первую очередь, а не как руководство к действию.
У нас есть процедура
dcl-proc chkCre;
проверка суммы займов клиента по определенным типам счетов на превышение заданного лимита
Возможно, если сделать чтобы функция считала, как число, полный дебет по счетам клиента и сохраняла его в текущем объекте, созданном для клиента, возможно это могло бы облегчить какие-то проблемы с перформансом, так как мы могли бы сэкономить на обращениях к базе данных, просто сравнивая однажды посчитанное число с разными порогами, которые последовательно применяются к этому клиенту. Да! Тут куча нюансов, которые надо анализировать, но в таком анализе и заключается ваша работа, насколько я понимаю. Но суть анализа на первом этапе, как раз состоит в том, чтобы решить:
1) что нужно иметь в коде в качестве объекта (нужно ли вам иметь объект, связанный с клиентом, как управлять временем жизни такого объекта, то есть когда его удалять, обновлять, …)
2) какие у этого объекта определить функции (насколько полезно привязать к такому объекту функцию «Процедура вычисления полного кредита» ),
3) что хранить в данных объекта (можно ли сохранить полный кредит для клиента, насколько долго, когда его обновлять, …).
Даже просто такого рода анализ это уже и есть применение ООП в какой-то степени, с моей точки зрения.
Второй ответ для @SpiderEkb по фрагменту кода иллюстрирующему "чисто процедурный подход" на С-подобном языке:
Вот вы действительно думаете что в таком виде операция "деление":
int opDiv(int a, int b) {return a / b;}
будет работать? То есть "23 / 78 = <..> " это нормальный пример для решения?
Ну не получится у вас просто запихать в функцию эту операцию, недостаточно этого, и даже с операцией "минус" будут похожие проблемы. А попробуйте определить операцию преобразования заданного числа в заданной системе счисления в число в другой системе счисления, у меня это очень просто сделать, а в вашем коде??? Концепция проверяется возможностью ее расширения для добавления новой функциональности, а у вас даже добавленное "деление", нормально не работает, и что же вы доказали?
Ну и получается, что вы прочитали статью, только для того чтобы найти в ней отражение своих представлений. В результате не подтверждения не нашли, и даже не попытались понять, что там написано, в чем проблема заключается. Там у меня есть ссылка на статью в которой формулируется эта проблема с "делением", и предлагается вариант решения этой проблемы, на мой взгляд самый удачный вариант.
Вы бы хоть почитали мои замечания к предудущему коду на Питоне в коментариях, там еще одна мелочь есть. Если вы не думаете о мелочах заранее, у вас в последствии программа начинает разваливаться под собственной тяжестью.