Закон Деметры

Введение


На данный момент существует множество доказанных временем практик, помогающих разработчикам писать хорошо поддерживаемый, гибкий и удобно читаемый код. Закон Деметры — одна из таких практик.

Поскольку мы говорим об этом законе в контексте написания хорошего кода, перед его рассмотрением я хотел бы выразить свое понимание того, каким должен быть хороший код. Прочитав «Совершенный код» Макконнелла, я твёрдо уверовал в то, что главный технический императив разработки ПО — управление сложностью. Управление сложностью — довольно обширная тема, так как понятие сложности применимо на любом уровне проекта. Можно говорить о сложности в контексте общей архитектуры проекта, в контексте взаимосвязей модулей проекта, в контексте отдельного модуля, отдельного класса, отдельного метода. Но, пожалуй, большую часть времени разработчики сталкиваются со сложностью на уровне отдельных классов и методов, поэтому общее, упрощенное правило управления сложностью я бы сформулировал следующим образом: «Чем меньше вещей нужно держать в голове, глядя на отдельный участок кода, тем меньше сложность этого кода». То есть программный код нужно организовывать так, чтобы можно было безопасно работать с отдельными фрагментами по очереди (по возможности не думая об остальных фрагментах).

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

Закон Деметры — один из рецептов, помогающих в борьбе со сложностью (а следовательно и с увеличением гибкости вашего кода).

Закон Деметры


Закон Деметры говорит нам о том же, о чем в детстве говорили родители: «Не разговаривай с незнакомцами». А разговаривать можно вот с кем:

— С методами самого объекта.
— С методами объектов, от которых объект зависит напрямую.
— С созданными объектами.
— С объектами, которые приходят в метод в качестве параметра.
— С глобальными переменными (что лично мне не кажется верным, так как глобальные переменные во многом увеличивают общую сложность)

Мы рассмотрим конкретный пример, а после сделаем выводы о том, каким образом закон Деметры помогает нам в написании хорошего кода.

public class Seller {
    private PriceCalculator priceCalculator = new PriceCalculator();

    public void sell(Set<Product> products, Wallet wallet) throws NotEnoughMoneyException {
        Money actualSum = wallet.getMoney(); // закон не нарушается, взаимодействие с объектом параметром метода (п. 4)
        Money requiredSum = priceCalculator.calculate(products);  // не нарушается, взаимодействие с методом объекта, от которых объект зависит напрямую (п. 2)

        if (actualSum.isLessThan(requiredSum)) { // нарушение закона.
            throw new NotEnoughMoneyException(actualSum, requiredSum);
        } else {
            Money balance = actualSum.subtract(requiredSum); // нарушение закона.
            wallet.setMoney(balance);
        }
    }
}

Закон нарушается потому, что из объекта, который приходит в метод параметром (Wallet), мы берём другой объект (actualSum) и позже вызываем на нем метод (isLessThan). То есть в конечном итоге получается цепочка: wallet.getMoney().isLessThan(otherMoney).

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

Более корректная версия, удовлетворяющая закону выглядела бы так:

public class Seller {
    private PriceCalculator priceCalculator = new PriceCalculator();

    public Money sell(Set<Product> products, Money moneyForProducts) throws NotEnoughMoneyException {
        Money requiredSum = priceCalculator.calculate(products);

        if (moneyForProducts.isLessThan(requiredSum)) {
            throw new NotEnoughMoneyException(moneyForProducts, requiredSum);
        } else {
            return moneyForProducts.subtract(requiredSum);
        }
    }
}

Теперь мы передаём в метод sell список продуктов на покупку и деньги за эти продукты. Этот код кажется более естественным и понятным, он улучшает уровень абстракции метода:

sell(Set<Product> products, Wallet wallet) VS sell(Set<Product> products, Money moneyForProducts ).

Теперь этот код стало легче тестировать. Для тестирования достаточно создать объект Money, тогда как до этого необходимо было создать объект Wallet, затем объект Money, а затем положить Money в Wallet (возможно объект wallet был бы достаточно сложным и на него пришлось бы писать mock'и, что ещё больше увеличило бы общую сложность теста).

Пожалуй самое важное — это то, что сопряжение уменьшилось и метод стало куда легче использовать. Теперь опция продажи никак не зависит от наличия кошелька — она зависит только от наличия денег. Неважно откуда приходят деньги — из кошелка, кармана или кредитной карточки, этот метод не будет завязан на «хранилище» денег.

Когда я читал про закон Деметры, мне часто казалось, что соблюдение других принципов/инструментов написания хорошего кода (SOLID, DRY, KISS, паттерны и т.д.) просто не могут привести к ситуации, когда этот закон нарушается.

Лично для меня закон Деметры не является «правилом №1». Скорее так: если этот закон нарушается, для меня это повод задуматься о том, всё ли я делаю правильно.

Важно, чтобы соблюдение закона не являлось самоцелью, потому что сам закон не имеет семантики, и слепое соблюдение закона может только усложнить код. Например:

public class Seller {
    private PriceCalculator priceCalculator = new PriceCalculator();

    public void sell(Set<Product> products, Wallet wallet ) throws NotEnoughMoneyException {
        Money requiredSum = priceCalculator.calculate(products);  // закон не нарушается, п. 2
        Money actualSum = wallet.getMoney();  // закон не нарушается, п. 4

        Money balance = subtract( actualSum, requiredSum);
        wallet.setMoney(balance);
    }

    private Money subtract(Money first, Money second) throws NotEnoughMoneyException {
        if (first.isLessThan(second)) {  // закон не нарушается, п. 4
            throw new NotEnoughMoneyException( first, second);
        } else {
            return first.subtract(second);  // закон не нарушается, п. 4
        }
    }
}

Формально каждый метод удовлетворяет закону Деметры. Фактически — он обладает всеми теми же недостатками, какими обладал самый первый вариант (в публичном методе. Частый совет, который я встречал в подобных случаях — следить не только за соблюдением закона, но и за количеством зависимостей класса, количеством методов в классе и за числом аргументов в методах.

Выводы


Теперь давайте обобщим, какие именно преимущества мы получаем при соблюдении закона.

— Уменьшение связанности кода. Достигается за счёт того, что классы общаются только со своими близкими родственниками (с собой, аргументами метода и прямыми зависимостями).

— Сокрытие (структурной) информации. Благодаря закону Деметры мы избегаем ситуаций, когда мы вытаскиваем из объекта его части (деньги из кошелька в нашем примере) и манипулируем ими. После применения закона мы манипулируем только этими частями (только деньгами). Это позволяет избежать лишних знаний, используя лишь то, что нам нужно (и как правило улучшает общий уровень абстракции).

— Локализация информации. При соблюдении закона мы исключаем цепочки в виде someClass.getOther().getAnother().callMethod(), ограничивая круг возможных участников общения. Это помогает гораздо легче ориентироваться в написанном коде, уменьшая интеллектуальное напряжение при чтении кода.

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

Когда можно не использовать закон Деметры?


— При взаимодействии с core jdk классами. Например, seller.toString().toLowerCase() формально нарушает закон Деметры, но если он используется, например, в контексте логирования, в этом нет ничего страшного.

— В DTO-классах. Причина создания DTO классов — это трансфер объектов, и цепочки вызова методов DTO-классов противоречат закону Деметры, но вписываются в идею самих DTO-объектов.

— В коллекциях. warehouses.get(i).getName() — формально тоже противоречит закону, но не противоречит идее коллекции.

— Руководствуйтесь здравым смыслом ;)
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 27

    0
    Дополню альтернативной и спорной точкой зрения.

    http://www.yegor256.com/2016/07/18/law-of-demeter.html
    • UFO just landed and posted this here
        0
        Оригинал Law of Demeter (статья 1988 года) находится тут: Object-Oriented Programming: An Objective Sense of Style. Там есть уточняющие формулировки закона для Lisp, C++ и Eiffel.

        Я в свое время думал написать плагин для LLVM, который бы делал проверку этого закона, но предварительные эксперименты по внедрению такой проверки в стандарты кодирования провалились. Причиной тому была не чистая функциональность используемых языков программирования (в данном случае это были C++ и Python) — из-за того, что объекты можно передавать по ссылке, контролировать операции над ними не представлялось возможным (особенно, если это были библиотечные вызовы). В итоге мы использовали другие критерии, как например, тестируемость кода и ограничение cyclomatic complexity.
          0

          А разве объект (внеся изменения в суперкласс) нельзя было сделать таким, чтобы он сам следил и "докладывал" о нарушениях "закона Деметры" в отношении себя — например, сообщал, что его передали по ссылке не тому, с кем ему можно общаться или наоборот — вызвал недопустимого объекта?

        0

        По-моему, в ней нет ничего альтернативного. Лишь более правильная интерпретация, чем наиболее распространённая. В приведённом там примере использование метода textOfLastPage() не устраняет необходимость вызывающего кода знать внутреннее устройство вызываемого объекта. Ведь этот метод не может в какой-то момент начать возвращать текст первой страницы вместо последней… или вообще не текст, а число, обозначающее количество слов на странице. Для этого метод придётся переименовать, поскольку внутреннее устройство и реализация закодированы прямо в его названии.

        0
        Мне кажется класс Money как кортеж «валюта-сумма» лучше бы был иммутабельным
          0
          Согласен, но почему вы решили, что он mutable?

          У класса нет методов, меняющих его внутреннее состояние, а методы вроде subtract задуманы возвращать новый объект Money (результат вычисления).
            0
            Да, все верно, прошу прощения. Я слишком быстро и невнимательно читал :)
          0
          Можно вопрос — почему используется выражение moneyForProducts.subtract(actualSum), а не actualSum.subtract(moneyForProducts)?
            0
            Это опечатка, спасибо что нашли её — исправил)
            0
            А что делать если метод sell требует и Wallet и Money?
              0
              Если метод sell требует Wallet — это проблема дизайна.
              Зависимость от Wallet в методе sell — это как раз то, от чего мы пытались уйти.
              Метод нужен для осуществления продажи и должен зависеть от денег, но никак не от хранилища денег.
                0
                И все же предположим он нужен и с дизайном все хорошо. Мой вопрос не о дизайне метода sell и не о самом методе. а о том как решить проблему если есть такая вот зависимость от Wallet и Money
                  0
                  Если методу sell для продажи понадобился Wallet, то с дизайном всё же не очень хорошо. Если вы считаете, что существует ситуация, при которой sell должен знать о Wallet, и это хороший дизайн, пожалуйста, приведите конкретный пример, и тогда я смогу ответить.
                    0
                    Вот пример. Бизнесаналитик говорит что в момент продажи необходимо помимо всего прочего вызвать еще и метод doSomethingImportant() из Wallet.
                    Как быть в этой ситуации? Как поступите?
                  0
                  > Если метод sell требует Wallet — это проблема дизайна.

                  Если метод sell требует Money — это проблема дизайна.

                  Не знаю, почему у вас sell возвращает остаток денег в кошельке. Обычно при продаже формируется чек. Там есть не только деньги, но и счета списания и зачисления.

                  Где у вас защита от race condition?
                    0
                    > Не знаю, почему у вас sell возвращает остаток денег в кошельке

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

                    > Обычно при продаже формируется чек
                    > Где у вас защита от race condition?

                    Цель этой статьи — показать пользу от применения закона в отдельно взятом, выдуманном случае. Этот случай недостаточно реален для того, чтобы он ровно в таком же виде возник у вас на проекте, но достаточно реален для того, чтобы из него можно было сделать какие-то выводы.

                    Я не ставил перед собой цели писать thread-safe пример, т.к. лишние строчки обработки race-condition'ов отвлекали бы читателя от основной мысли, которую я хотел донести. Я считаю, что это допустимо в рамках примера, хотя, возможно, следовало упомянуть об этом в начале статьи.
                      0

                      Всё же статья смотрелась бы серьёзнее, если бы пример был не выдуманный, а реальный. Меня, например, статья не убедила, что преобразования сделали код лучше.

                        0
                        > Второй (более корректный пример) взаимодействует только с деньгами и возвращает сдачу.

                        У вас бизнес-процесс поменялся, что это стало «сдачей»? Сдача не с содержимого кошелька, а с одной из купюр из кошелька. У вас в примере не сдача.

                        Я напомню, что тред начался с «А что делать если метод sell требует и Wallet и Money?». И иметь и то и другое — нормально, если это не отдельно взятый выдуманный случай.

                        Если вы выдумываете плохие примеры — не надо с других спрашивать нормальные.
                          0
                          Сложно что — либо возразить — пример действительно не очень удачный.
                            0
                            Я это не к тому, что пример плох, а к тому, что вопрос gzhernov вполне корректен и на него надо бы ответить. Если есть что.
                  0
                  А вам не кажется что методу subtract самое место в классе Wallet?

                  Зачем объекту Seller знать сколько денег находится в кошельке и тем более пытаться самому их оттуда извлечь, если его дело — посчитать суммарную стоимость всех продуктов и сказать «кошельку», сколько тот должен заплатить.
                  Хотя, конечно, продавец скорее должен взаимодействовать с покупателем, а вовсе не с его кошельком напрямую.
                    +1
                    А меня больше смущает, что если передавать wallet в метод, то совершенно не ясно, изменяется ли его состояние внутри или нет
                    public Money sell(Set<Product> products, Wallet wallet) throws NotEnoughMoneyException
                    

                    в отличие от
                    public Money sell(Set<Product> products, Money moneyForProducts) throws NotEnoughMoneyException
                    

                    когда априори, изменяться нечему
                      0

                      В первом случае по-хорошему состояние изменяться должно, потому что метод называется sell. То что оно не меняется, очень странно.


                      Второй метод sell, который производит вычитание, по факту ничего не продаёт. Он мне нравится ещё меньше, чем первый, потому что непонятно, зачем он такой нужен.


                      А вообще спорные вопросы вроде изменяется ли передаваемый объект, стоит аккуратно расписывать в JavaDoc. Тогда всё будет хорошо.

                        0
                        Спасибо за ваш комментарий, вы правы.
                          0
                          Вы правы. Но я отдал предпочтение 2ому варианту исходя из следующих мыслей:
                           1) приводимый пример автором мне показался больше академическим, нежели боевым, иначе тогда нужно
                              упомянуть вопросы логирования, рейс-кондишина, отката и др.
                           2) первый вариант показался бы странным, если объект Wallet был неизменяемым, если же он изменяемый, 
                              то зачем тогда возвращать остаток?
                           
                           Но все равно, теперь вы заставили меня сомневаться, что лучше?
                            0
                            Адресовано lany, никак не могу покорить систем комментариев здесь

                      Only users with full accounts can post comments. Log in, please.