Programming
Designing and refactoring
February 2015 3

KISS — принцип проектирования, содержащий все остальные принципы проектирования

Постараюсь объяснить сущность принципа проектирования KISS просто и одновременно очень подробно. KISS – это очень общий и абстрактный принцип проектирования, который содержит в себе практически все остальные принципы проектирования. Принципы проектирования описывают как писать «хороший» код. Однако что значит хороший код? Некоторые считают, что это код, который выполняется максимально быстро, некоторые – что это код, в котором задействовано как можно больше паттернов проектирования… Но верный ответ лежит на поверхности. Код – это информация в чистом виде. А основные критерии ценности информации – это 1)достоверность 2)доступность 3)понятность. То, почему важны достоверностью и доступность – очевидно. От кода нет проку, если он работает с ошибками или если сервер с приложением «лежит». Почему же важна понятность кода? В понятном коде проще искать ошибки, проще его изменять, дорабатывать и сопровождать. Итак, понятность – основная ценность, к которой должен стремиться программист. Однако тут есть одна неувязочка. Дело в том, что понятность – вещь сугубо субъективная. Нужен некий более объективный критерий понятности. И этот критерий – простота. Действительно, простое приложение более понятное, нежели сложное. Однако простоты достичь сложно. Вот что пишет Питер Гудвин в книге «Ремесло программиста»:
Если проект прост, его легко понять… Разработать простой проект не так легко. Для этого нужно время. Для всякой сколько-нибудь сложной программы окончательное решение получается в результате анализа огромного объема информации. Когда код хорошо спроектирован, кажется, что он и не мог быть иным, однако возможно, что его простота достигнута в результате напряженного умственного труда (и большого объема рефакторинга). Сделать простую вещь сложно. Если структура кода кажется очевидной, не надо думать, что это далось без труда.

Итак, принцип проектирования KISS (keep it simple and straightforward) провозглашает, что простота кода – превыше всего, потому что простой код – наиболее понятный.
Практически все принципы проектирования направлены на достижение понятности кода. Нарушая какой-либо принцип проектирования, вы уменьшаете понятность кода. Непонятный код автоматически вызывает у человека ощущение того, что код сложный, так как его сложно понимать и модифицировать. При нарушении любого из этих принципов также нарушается и принцип KISS. Поэтому можно говорить, что KISS включает почти все остальные принципы проектирования.
Патерны проектирования описывают наиболее удачные, простые и понятные решения некоторых проблем. Если вы используете паттерн проектирования там, где нет проблемы, которую решает данный паттерн – то вы нарушаете KISS, внося ненужные усложнения в код. Если вы НЕ используете паттерн проектирования там, где есть проблема, соответствующая паттерну – то вы опять-таки нарушаете KISS, делая код сложнее, чем он мог бы быть.

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

Всвязи с тем, что представления разных людей о таком понятии как «простота» могут различаться, приобрели широкое распространение следующая заблуждения относительно KISS-a:
Заблуждение 1. Если считать, что простой код – это такой код, который проще всего написать, то можно истолковать, что принцип KISS призывает писать первое что взбредёт в голову, вообще не задумываясь о проектировании.
Заблуждение 2. Если считать, что простой код – это такой код, для написания которого требуется как можно меньше знаний, то можно истолковать, что принцип KISS призывает не использовать паттерны проектирования.

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

Пример на C#



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

Как выглядит первое что приходит в голову:

Развернуть код
    public interface IShape
    {
    }

    public class Circle : IShape
    {
    }

    public class Rectangle : IShape
    {
    }

    public class RoundedRectangle : IShape
    {
    }

    public class IntersectionFinder
    {
        public IShape FindIntersection(IShape shape, IShape shape2)
        {
            if (shape is Circle && shape2 is Rectangle)
                return FindIntersection(shape as Circle, shape2 as Rectangle);
            
            if (shape is Circle && shape2 is RoundedRectangle)
                return FindIntersection(shape as Circle, shape2 as RoundedRectangle);
            
            if (shape is RoundedRectangle && shape2 is Rectangle)
                return FindIntersection(shape as RoundedRectangle, shape2 as Rectangle);
            
            return FindIntersection(shape2, shape);
        }

        private IShape FindIntersection(Circle circle, Rectangle rectangle)
        {
            return new RoundedRectangle(); //также код мог бы вернуть Rectangle или Circle, в зависимости от их размеров. Но для простоты будем считать что метод всегда возвращает RoundedRectangle
        }

        private IShape FindIntersection(Circle circle, RoundedRectangle rounedeRectangle)
        {
            return new Circle();
        }

        private IShape FindIntersection(RoundedRectangle roundedRectanglerectangle, Rectangle rectangle)
        {
            return new Rectangle();
        }  
    }



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

Развернуть код

public interface IShape
    {
        IShape FindIntersection(IShape shape);
    }

    public class Circle : IShape
    {
        public IShape FindIntersection(IShape shape)
        {
            if (shape is Rectangle)
                return FindIntersection(shape as Rectangle);

            if (shape is RoundedRectangle)
                return FindIntersection(shape as RoundedRectangle);

            return shape.FindIntersection(this);
        }

        private IShape FindIntersection(Rectangle rectangle)
        {
            return new RoundedRectangle();//также код мог бы вернуть Rectangle или Circle, в зависимости от их размеров. Но для простоты будем считать что метод всегда возвращает RoundedRectangle
        }

        private IShape FindIntersection(RoundedRectangle rounedeRectangle)
        {
            return new Circle();
        }
    }

    public class Rectangle : IShape
    {
        public IShape FindIntersection(IShape shape)
        {
            if (shape is RoundedRectangle)
                return FindIntersection(shape as RoundedRectangle);

            return shape.FindIntersection(this);
        }

        private IShape FindIntersection(RoundedRectangle roundedRectangle)
        {
            return new Rectangle();
        }
    }

    public class RoundedRectangle : IShape
    {
        public IShape FindIntersection(IShape shape)
        {
            return shape.FindIntersection(this);
        }
    }




Отлично, теперь незнакомому с кодом программисту не придётся искать по всему проекту способ сделать пересечение двух фигур. Исчезла лишняя сущность «Вычислитель Пересечений». Код стал более естественным и легко доступным для понимания, а значит — более простым. Теперь при создании нового типа фигуры не нужно вносить изменения в ранее созданные классы, а значит добавление новых типов фигур также упростилось. Проще найти конкретный алгоритм поиска пересечения, так как теперь не нужно искать его в гигантском классе среди множества методов с одинаковыми именами.

Но теперь замечаем, что способ принятия решения, какую конкретно функцию вычисления пересечения нужно вызывать, не лишён искусственности. Более естественный подход звучал бы так: вызвать функцию с именем FindIntersection, тип аргумента которой совпадает с типом второй фигуры.

Развернуть код

public class Shape
    {
        public Shape FindIntersection(Shape shape)
        {
            var method = MethodFinder.Find(this.GetType(), "FindIntersection", shape.GetType());

            if (method != null)
            {
                return (Shape)method.Invoke(this, new[] { shape });
            }

            return shape.FindIntersection(this);
        }
    }

    public class Circle : Shape
    {
        [UsedImplicitly]
        private Shape FindIntersection(Rectangle rectangle)
        {
            return new RoundedRectangle();//также код мог бы вернуть Rectangle или Circle, в зависимости от их размеров. Но для простоты будем считать что метод всегда возвращает RoundedRectangle
        }

        [UsedImplicitly]
        private Shape FindIntersection(RoundedRectangle rounedeRectangle)
        {
            return new Circle();
        }
    }

    public class Rectangle : Shape
    {
        [UsedImplicitly]
        private Shape FindIntersection(RoundedRectangle roundedRectangle)
        {
            return new Rectangle();
        }
    }

    public class RoundedRectangle : Shape
    {
    }

    public static class MethodFinder
    {
        public static MethodInfo Find(Type classType, string functionName, Type parameterType)
        {
            return
                classType.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
                .FirstOrDefault(
                    x => x.Name == functionName
                    && x.GetParameters().Count() == 1 
                    && x.GetParameters().First().ParameterType == parameterType);
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Shape shape = new Rectangle();
            var shapeIntersection = shape.FindIntersection(new Circle());
            Console.WriteLine(shapeIntersection.GetType());
        }
    }



Как видно, из каждого конкретного класса фигуры исчезли методы public IShape FindIntersection(IShape shape), общее количество строк кода сократилось. Теперь добавлять новые типы фигур стало ещё проще. Метод FindIntersection(Shape shape) теперь находится в базовом классе и выглядит более просто и естественно (декларативно). Добавился новый класс MethodFinder, однако программисту не нужно знать его внутреннее устройство, т.к. он имеет понятный интерфейс и не реализует понятия из предметной области (а значит причины для его изменений будут редки), поэтому сложность кода практически не возросла при его добавлении.
Тут может возникнуть мысль, что рефлексия — медленная штука, и для ускорения можно, например, кэшировать делегаты, динамически сформированные посредством ExpressionTree, однако KISS призывает писать как можно более простой код, поэтому стоит воздержаться от этой мысли до тех пор, пока быстродействие метода FindIntersection(Shape shape) действительно не станет узким местом программы, создающим проблемы для пользователя. Но вот что не следует откладывать, так это создание юнит-теста, который через рефлексию узнаёт всех наследников класса Shape и проверяет, что программист не забыл реализовать алгоритмы поиска пересечения для всех пар фигур.

Посмотреть код теста
    [TestFixture]
    public class ShapeTest
    {
        [Test]
        public void AllIntersectsMustBeRealized()
        {
            var shapeTypes = typeof(Shape).Assembly.GetTypes().Where(x => x.IsSubclassOf(typeof (Shape)));

            var errorMessages = new List<string>();

            foreach (var firstType in shapeTypes)
            foreach (var secondType in shapeTypes)
            {
                if (MethodFinder.Find(firstType, "FindIntersection", secondType) == null)
                {
                    errorMessages.Add(string.Format("Не удалось найти метод для поиска пересечения фигур: {0} и {1}", firstType.Name, secondType.Name));
                }
            }

            if (errorMessages.Any())
                throw new Exception(string.Join("\r\n", errorMessages));
        }
    }



Сравнив взглядом первый и третий пример, может показаться не очевидным, что третий пример проще. Однако давайте, представим, что типов фигур не 3, а 30. Тогда количество функций сравнения фигур — 465 (сумма арифметической прогрессии (1+30)*30\2). В первом случае механизм выбора нужной функции будет скрыт за 465 if-ами (или, как вариант, за контейнером с 465-ю указателями на методы, что не сильно лучше), и среди этого нагромождения if-ов незнакомому с кодом программисту нужно будет усмотреть некую систему. Тогда как в 3-м случае подход декларативен и не зависит от количества типов фигур. Этот пример хорош тем, что значительной части программистов может показаться, что третий пример является плохим решением, так как в нём используется рефлексия для доступа к приватным переменным (что является своеобразным табу в среде программистов), потому что они слышали из авторитетных источников, что использовать рефлексию для таких целей плохо, но не могут объяснить, почему это плохо. Этот психологический феномен называется фиксированностью ценностей.

Узнать про феномен фиксированности ценностей
Описание феномена взято из книги Чеда Фаулера Программист-фанатик и демонстрируется на примере ловли обезьян.
Жители Южной Индии, которых на протяжении многих лет донимали обезьяны, придумали оригинальный способ их ловли. Они выкапывали в земле глубокую узкую нору, затем тонким предметом такой же длины расширяли дно норы. После этого в более широкую часть внизу норы насыпали рис. Обезьяны любят поесть. На самом деле, в основном именно из-за этого они такие докучливые. Они будут запрыгивать на машины или рисковать, пробегая через большую толпу людей, чтобы выхватить еду прямо из ваших рук. Жители Южной Индии слишком хорошо знают об этом. (Поверьте мне, очень неприятно, когда вы стоите посреди парка и неожиданно на огромной скорости на вас начинает бежать макака, чтобы выхватить что-нибудь.) Итак, обезьяны подходили, находили рис и засовывали руки в нору. Их руки оказывались внизу. Они жадно захватывали как можно больше риса, постепенно складывая ладони в кулаки. Кулаки занимали объём широкой части норы, а верхняя часть была настолько узка, что обезьяна не могла протиснуть через неё кулаки. И оказывалась в ловушке. Конечно, они могли бы просто отказаться от еды и остаться на свободе. Но обезьяны придают большое значение еде. На самом деле еда для них настолько важна, что они не могут заставить себя отказаться от неё. Они будут сжимать рис до тех пор, пока не вытащат из-под земли или не умрут, пытаясь вытащить его. Обычно второе наступало раньше. Фиксированность ценностей это когда вы верите в значимость чего-либо настолько сильно, что больше не можете это объективно подвергнуть сомнению. Обезьяны оценивают рис настолько высоко, что когда им приходится выбирать между рисом и смертельным пленом, они не могут понять, что сейчас лучше потерять рис. История представляет обезьян очень глупыми, но большинство из нас имеет свой эквивалент риса. Если бы вас спросили, хорошо ли помогать с пропитанием голодающим детям стран третьего мира, вы скорее всего не задумываясь ответили бы «да». Если бы кто-нибудь попытался оспорить вашу точку зрения, вы бы решили что он сумасшедший. Это пример фиксированной ценности. Вы убеждены в чем-то настолько сильно, что не можете представить, как можно не верить в это. И в данном случае фиксированная ценность это вера в то, что использовать рефлексию для доступа к приватным методам — плохо.


Узнать почему использование рефлексии для доступа к приватным методам в данном случае не является святотатством
На самом деле, вызывать приватные методы вне типа данных, внутри которых объявлен метод — это нарушение инкапсуляции. Однако, как бы удивительно это не прозвучало, в данном примере инкапсуляция не нарушается. Концептуально класс-родитель и класс-наследник являются одним типом данных. Код родителя, инкапсулированный от внешнего мира, может вызываться в наследнике (protected), в то время как родитель может вызывать инкапсулированные методы наследника (protected virtual). Если вы копаетесь во «внутренностях» класса-наследника, вам неизбежно придётся просмотреть и внутреннее устройство класса-родителя, а если у родителя в свою очередь тоже есть родитель, то и его внутреннее устройстве тоже. Многие разработчики знают об этой особенности и предпочитают использовать композицию вместо наследования (если ситуация позволяет сделать это).


Этот пример наглядно демонстрирует, как, используя KISS и стремясь сделать код более простым, можно придти к лучшему решению проблемы, даже если ваше неверное понимание тех или иных принципов или табу приказывает вам использовать «костыль» вместо декларативного кода, полностью отражающего намерение разработчика.

Немного истории.
Принцип KISS зародился в авиастроении и исторически переводится как «Keep it simple stupid» и расшифровывается как «сделайте это до идиотизма простым». В истории авиастроения известны случаи, когда слишком усердные рабочие прибивали на самолёт лишние пластины брони, чтобы сделать самолёт более живучим в бою, в результате чего масса самолёта становилась больше расчётной и самолёт попросту не мог взлететь. Кроме того, квалификация многих рабочих была низкой. В таких условиях конструкции самолётов, которые пьяный неквалифицированный рабочий не смог бы собрать неправильно, даже если бы захотел, обладали особенной ценностью. Один из отголосков конструкторских решений того времени — невозможность перепутать и воткнуть неверный штекер в гнездо внутри компьютера. Однако, если результатом труда авиа-инженера является чертёж, по которому будет создан продукт, то в случае с программистом продуктом является сам чертёж (образно выражаясь). В случае программиста он должен написать код так, чтобы пьяный неквалифицированный программист смог внести в него изменения в соответствии с изменившимися бизнес-требованиями (то есть изменить чертёж, а не собрать самолёт). В силу различий в специфике авиастроения и программирования, расшифровка «Keep it simple stupid», подходящая в авиастроении, уже не так хорошо отражает суть принципа для программиста. Многие ленивые программисты расшифровывают «сделайте это до идиотизма простым» как «не утруждайте себя проектированием» (сравните, например, описание принципа KISS в этой статье с вот этим описанием). К счастью, у KISS есть ещё и некоторые другие расшифровки, одна из которых, на мой взгляд, лучше всего отражает суть KISS в программировании — «keep it simple and straightforward». Straightforward переводится как простой, честный, прямолинейный, откровенный. «Keep it simple and straightforward», таким образом, можно вольно перевести как «Сделайте это простым и декларативным», а для достижения декларативности требуется проектирование.

За пример следует благодарить Hokum, который подал первоначальную идею для примера, которую я немного изменил.
-4
25.1k 82
Comments 84
Top of the day