Pull to refresh

«Чистый» код, нет проблем с производительностью. (плюс анекдот)

Level of difficultyEasy
Reading time14 min
Views17K

Последнее время мне приходится много ревьювить, анализировать, рефакторить 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;
        }

Я уверен, что большинство знающих язык С# смогут написать лучше, чем у меня тут получилось, было бы кому тестировать.

Заметьте, операции отличаются не только самим действием операции, они также могут отличаться разрядностью чисел участвующих в операции. Для деления генерируются цифры от 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# вместо С++ по производительности.

Вопросы производительности и анекдот в тему

Даже если бы мы писали ту же самую программу на С++ мы не смогли бы применить изложенные в статье оппонента методы измерения производительности, минимум по трем причинам:

  1. у нас нет точной последовательности обращения к объектам, с которыми происходит работа;

  2. программа зависит от ввода данных человеком;

  3. программа использует функции, предоставленные системой (функции консоли).

Даже если исходить из того, что вызовы виртуальных функций занимают больше времени чем заменяющие их переходы в 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 = <..> " это нормальный пример для решения?

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

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

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

Tags:
Hubs:
Total votes 16: ↑5 and ↓11-6
Comments55

Articles