«Чистый» код, нет проблем с производительностью. (плюс анекдот)
Последнее время мне приходится много ревьювить, анализировать, рефакторить C# код. Практика показывает, что принципы Объектно-Ориентированного Программирования не просто вызывают затруднения в понимании, и в применении, в большинстве случаев разработчики просто избегают их использования на практике. Я очень надеюсь, что мой относительно простой пример, который можно не только скомпилировать и выполнить, но и написать свой класс расширения и снова скомпилировать, и оценить результат уже своего труда внутри небольшой законченной программы, надеюсь это поможет кому-то преодолеть барьер к использованию принципов ООП на практике.
Конечно, мне придется продемонстрировать применение на практике принципов ООП: инкапсуляция, наследование, полиморфизм и то, как они работают.
Конечно, разберем как все это влияет на производительность. Я надеюсь, что получится сформулировать некое подобие ответа на критику постулатов «чистого» кода от достаточно успешного (видимо) иностранного программиста в переводе на Хабре: «Чистый» код, ужасная производительность.
Правила «чистого» кода, изложенные в той статье помогут нам подойти к реализации принципов ООП в нашей задаче.
Мне довелось лицезреть множество абстрактных оторванных от жизни примеров с кошками, собачками, машинками, трансформерами, треугольниками, кружочками, … которые призваны объяснить принципы ООП. Ну не серьезно как-то это все. Все такие примеры приводят к тому, мне кажется, что начинающие программисты, которые уже решают реальные практические задачи, так и относятся к этой концепции, как к какой-то красивой, но абстрактной идее, которую никто не смог применить на практике. Давайте попробуем разобрать задачу, которую не только интересно порешать, но также интересно поработать с результатом ее решения, или даже попросить кого-то поработать с этим результатом чтобы оценить то, что называется «user experience» со стороны.
Формулировка задачи и того, где она может быть полезна
Сразу хочу обратить внимание: пример, который я собираюсь рассмотреть, никак не будет связан с функциональностью баз данных, хранением, доступом к данным. Я предлагаю сосредоточиться только на логике генерации арифметических выражений, проверке их решения пользователем и взаимодействии с пользователем программы при такой проверке. Для максимальной простоты и компактности примера мы будем использовать консольный ввод вывод. Но если кто-то захочет использовать идею и продавать соответствующую визуальную версию программы для Андроида например, буду благодарен за любые отчисления с продукта, Шутка :) .
Мы будем рассматривать нашу программа-задачу как будто существует некоторое задание на разработку этой программы непрофессионально сформулированное, но вполне понятное (как это обычно и происходит на практике).
Итак, мы должны написать прототип программы, которая будет проверять способность к устным вычислениям школьника, хотя, кто может гарантировать что в ходе какого-нибудь исследования (про него см. далее) не появится интереса собрать статистику по взрослым? А значит нельзя исключать расширения списка операций в любую сторону.
1. Программа должна случайным образом выбирать арифметическую операцию, из списка, например: умножение, деление, сложение-вычитание двухзначных чисел, … (возможно даже составные примеры, примеры со скобками)
2. для этого должны случайным образом генерироваться числа для формирования операции для пользователя;
3. выводить такой случайный пример для решения-вычисления и ввода ответа пользователем;
4. ждать ответа, или команды на завершение программы, если решили зациклить программу (пускай по символу «q», например);
5. похвалить в случае правильного ответа или сообщить правильный результат в случае ошибки.
6. Можно вывести некоторую статистику по завершении программы.
Я даже рискну изложить здесь описание некоторого практического контекста, в рамках которого, гипотетически, могла бы использоваться программа-задача, которую мы будем рассматривать. Возможно, это поможет мне сократить количество критики относительно практической ценности такой обучающей задачи. Хотя не приходится, конечно, надеяться, что критики совсем не будет. Критика в любом случае приветствуется, это не только мое отношение, это политика Хабра, насколько я понимаю.
Представьте, что некоторая образовательная структура собирается провести исследование статистики по способностям школьников младших классов к устному счету. Требуется написать программу, которая случайным образом генерирует простые (и не очень) арифметические примеры, фиксирует-регистрирует ответы и, в каком-то виде, сохраняет их для дальнейшего анализа. Формат исследования предполагает, что параметры тестовых заданий и их содержание могут изменяться, варьироваться, дополняться на разных этапах … и т. д. … и т. п.
Возможно, кому-то покажется что требования слишком общие и/или плохо сформулированы, но заказчик и не должен хорошо разбираться в том, для чего он пригласил нас как специалистов, наша задача состоит, в том числе, в том, чтобы продемонстрировать заказчику возможности программы, которую мы собираемся ему предложить, чтобы удовлетворить даже те его потребности, о которых он может иметь даже ошибочное представление.
Вообще, надо заметить, что задача интересна в том числе тем, что ее можно практически бесконечно уточнять и в то же время расширять для покрытия все новых и новых требований, например на способы контроля заданных уровней вероятности разного типа операций, последовательности выпадающих чисел, регистрации разного рода аспектов процесса, статистик и т. д. и т. п.
Я постараюсь, насколько это возможно, не отвлекаться на то, что можно добавить в такую программу, я постараюсь сосредоточиться на демонстрации применения принципов ООП при минимальном количестве кода и на оценке производительности полученного решения с реализованными принципами ООП.
Первая версия программы
Рассмотрим для начала код с реализацией пары операций (»*» умножить и »-» минус):
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;
}
Я уверен, что большинство знающих язык С# смогут написать лучше, чем у меня тут получилось, было бы кому тестировать.
Да, кстати, для операции деления код писать не буду, она почему-то очень не понравилась аудитории моей предыдущей статьи про эту операцию.
Если скопировать все это в класс Program консольного C# проекта, все должно заработать, обычно после исправления пары простых опечаток, которые каким-то волшебным образом всегда остаются не замеченными.
консольный вывод для программы с классами
Повторяя за статьей оппонента, можно подвести итог:
Как и требовалось по правилам, мы используем полиморфизм. Наши функции выполняют только одну задачу (в основном, но это легко довести до конца). Они короткие. Все эти хорошие штуки. В итоге у нас получается «чистая» иерархия классов, где каждый дочерний класс знает, как вычислить свою площадь, и хранит данные, необходимые для вычисления площади выполнять разные функции, привязанные к представлению этого конкретного объекта как обезличенного объекта базового класса.
Теперь попробуем разобраться на сколько лет назад нас отбросило применение ООП, да еще и вместе с C# вместо С++ по производительности.
Вопросы производительности и анекдот в тему
Даже если бы мы писали ту же самую программу на С++ мы не смогли бы применить изложенные в статье оппонента методы измерения производительности, минимум по трем причинам:
1) у нас нет точной последовательности обращения к объектам, с которыми происходит работа
2) программа зависит от ввода данных человеком
3) программа использует функции, предоставленные системой (функции консоли)
Даже если исходить из того, что вызовы виртуальных функций занимают больше времени чем заменяющие их переходы в switch/IF, а это правда хотя разница микроскопическая и зависит от контекста в общем случае. Но исследование из статьи оппонента в этом смысле нас не обманывает. Только в нашем случае это увеличение времени локальных кусочков кода в главном цикле программы будет ничтожно мало по сравнению с теми задержками, которые формируют обращения к консоли. Короче говоря, никаких проблем с производительностью для ООП реализации в этой задаче вы не сможете обнаружить.
В какой-то степени десятикратный результат снижения производительности в задаче оппонента можно назвать подтасовкой так как:
А. задача была специально подобрана, по сути сконструирована, чтобы получить тот результат, который и был получен
Б. была использована аккумуляция-накопление по нескольким аспектам, которые в других задачах никогда не суммируются. Обычно эффекты, связанные с вызовом виртуальных функций сильно размазаны по алгоритму, поэтому ни на что не влияют, а в статье оппонента их как будто специально саккумулировали.
Таким образом то что в нашей задаче не будет проблем с производительностью это не случайность, это нормальная ситуация.
А вот задача оппонента, если кто-то сможет найти ей практическое применение хотя бы гипотетическое, похоже, действительно требует использования подхода со структурами данных (классов без методов) вместо классов в стиле ООП.
По поводу того надо ли бояться ужасной производительности у меня есть подходящий, как мне кажется, анекдот собственного сочинения:
Очередная подруга Джеймс Бонда любуясь на себя в зеркало после сумасшедшей ночи спрашивает:
«Я страшная?»
На что Джеймс Бонд невозмутимо отвечает:
«Ты же знаешь, дорогая, я ничего не боюсь. Я бесстрашный.»
Заключение
Ну вот, я, кажется, написал обо всем что хотел или написал достаточно чтобы утомиться — трудно отделить одно от другого. Всегда остается чувство, что о чем-то забыл или что-то не до формулировал. Возможно, это то чувство из-за которого многие писатели становятся неврастениками. Я вроде пока держусь :). В связи с этим хотелось бы обратиться с призывом к читателю: помните, если вам чего-то в статье не хватает, возможно это признак того, что статья помогла вам шире открыть глаза. И не надо стесняться и маскировать свою точку зрения под недовольство способом изложения материала.
С уважением,
Автор