Введение


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

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

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

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

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


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

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

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

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() — формально тоже противоречит закону, но не противоречит идее коллекции.

— Руководствуйтесь здравым смыслом ;)
Поделиться с друзьями
-->

Комментарии (27)


  1. j_wayne
    16.01.2017 14:23

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

    http://www.yegor256.com/2016/07/18/law-of-demeter.html


    1. Patroskan
      16.01.2017 19:40
      +1

      Очень интересно. В книжке, на которую ссылается статься, формулировка закона для ООП немножко другая, чем на википедии:


      For all classes C, and for all methods M attached
      to C, all objects to which M sends a
      message must be instances of classes associated
      with the following classes:
      1. The argument classes of M (including C).
      2. The instance variable classes of C.

      Тут речь идет про классы, а не объекты. Что, в общем случае, логично и несколько расширяет возможности вызова методов в коде. Например, можно утверждать, что любой класс зависит от стандартной библиотеки языка и тогда a.b().length() вполне ОК.


      Мне импонирует формулировка закона, где используется "знание про объект". В ООП это называется интерфейсом (и это паттерн, а не реализация, так что это применимо и в Ruby, JavaScript и прочих). Если смотреть с точки зрения публичного интерфейса объекта (класса объекта), а не отдельного объекта, то все становится немного проще.


      А что самое интересное, эта книжка является самым ранним текстом, который мне удалось найти, упоминающим закон Деметры. Так что абстрагировались от классов, а потом и от объектов, и вообще добавили всякой отсебятины, уже куда позже.
      И так всегда. Начинаешь искать концы, пока не окажется, что ООП — не ООП, REST — не REST и вообще.
      Лучше почитать какую-то умную книжку, вникнуть в прекрасные идеи автора, и пользоваться потом собственной головой, а не придумывать инструкции механического набора кода на подобии "one-dot rule", как будто код набирает обезьяна.
      Последняя строчка статьи правильно говорит :)


      1. nuald
        17.01.2017 02:33

        Оригинал Law of Demeter (статья 1988 года) находится тут: Object-Oriented Programming: An Objective Sense of Style. Там есть уточняющие формулировки закона для Lisp, C++ и Eiffel.

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


        1. Vjatcheslav3345
          17.01.2017 09:24

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


    1. Shamov
      17.01.2017 11:16

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


  1. MzMz
    16.01.2017 16:28

    Мне кажется класс Money как кортеж «валюта-сумма» лучше бы был иммутабельным


    1. NikchukIvan
      16.01.2017 16:47

      Согласен, но почему вы решили, что он mutable?

      У класса нет методов, меняющих его внутреннее состояние, а методы вроде subtract задуманы возвращать новый объект Money (результат вычисления).


      1. MzMz
        16.01.2017 16:52

        Да, все верно, прошу прощения. Я слишком быстро и невнимательно читал :)


  1. reforms
    16.01.2017 21:59

    Можно вопрос — почему используется выражение moneyForProducts.subtract(actualSum), а не actualSum.subtract(moneyForProducts)?


    1. NikchukIvan
      16.01.2017 22:00

      Это опечатка, спасибо что нашли её — исправил)


  1. gzhernov
    19.01.2017 07:48

    А что делать если метод sell требует и Wallet и Money?


    1. NikchukIvan
      19.01.2017 07:52

      Если метод sell требует Wallet — это проблема дизайна.
      Зависимость от Wallet в методе sell — это как раз то, от чего мы пытались уйти.
      Метод нужен для осуществления продажи и должен зависеть от денег, но никак не от хранилища денег.


      1. gzhernov
        20.01.2017 13:54

        И все же предположим он нужен и с дизайном все хорошо. Мой вопрос не о дизайне метода sell и не о самом методе. а о том как решить проблему если есть такая вот зависимость от Wallet и Money


        1. NikchukIvan
          20.01.2017 22:42

          Если методу sell для продажи понадобился Wallet, то с дизайном всё же не очень хорошо. Если вы считаете, что существует ситуация, при которой sell должен знать о Wallet, и это хороший дизайн, пожалуйста, приведите конкретный пример, и тогда я смогу ответить.


          1. gzhernov
            25.01.2017 11:43

            Вот пример. Бизнесаналитик говорит что в момент продажи необходимо помимо всего прочего вызвать еще и метод doSomethingImportant() из Wallet.
            Как быть в этой ситуации? Как поступите?


      1. areht
        21.01.2017 13:36

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

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

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

        Где у вас защита от race condition?


        1. NikchukIvan
          21.01.2017 14:08

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

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

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

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

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


          1. lany
            21.01.2017 17:15

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


          1. areht
            21.01.2017 21:13

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

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

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

            Если вы выдумываете плохие примеры — не надо с других спрашивать нормальные.


            1. NikchukIvan
              21.01.2017 21:18

              Сложно что — либо возразить — пример действительно не очень удачный.


              1. areht
                21.01.2017 22:17

                Я это не к тому, что пример плох, а к тому, что вопрос gzhernov вполне корректен и на него надо бы ответить. Если есть что.


  1. cheshirrrr
    19.01.2017 14:52

    А вам не кажется что методу subtract самое место в классе Wallet?

    Зачем объекту Seller знать сколько денег находится в кошельке и тем более пытаться самому их оттуда извлечь, если его дело — посчитать суммарную стоимость всех продуктов и сказать «кошельку», сколько тот должен заплатить.
    Хотя, конечно, продавец скорее должен взаимодействовать с покупателем, а вовсе не с его кошельком напрямую.


  1. reforms
    19.01.2017 16:28
    +1

    А меня больше смущает, что если передавать wallet в метод, то совершенно не ясно, изменяется ли его состояние внутри или нет

    public Money sell(Set<Product> products, Wallet wallet) throws NotEnoughMoneyException
    

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

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


    1. lany
      21.01.2017 17:17

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


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


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


      1. NikchukIvan
        21.01.2017 18:27

        Спасибо за ваш комментарий, вы правы.


      1. reforms
        21.01.2017 18:51

        Вы правы. Но я отдал предпочтение 2ому варианту исходя из следующих мыслей:

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


        1. reforms
          21.01.2017 18:52

          Адресовано lany, никак не могу покорить систем комментариев здесь