Введение
На данный момент существует множество доказанных временем практик, помогающих разработчикам писать хорошо поддерживаемый, гибкий и удобно читаемый код. Закон Деметры — одна из таких практик.
Поскольку мы говорим об этом законе в контексте написания хорошего кода, перед его рассмотрением я хотел бы выразить свое понимание того, каким должен быть хороший код. Прочитав «Совершенный код» Макконнелла, я твёрдо уверовал в то, что главный технический императив разработки ПО — управление сложностью. Управление сложностью — довольно обширная тема, так как понятие сложности применимо на любом уровне проекта. Можно говорить о сложности в контексте общей архитектуры проекта, в контексте взаимосвязей модулей проекта, в контексте отдельного модуля, отдельного класса, отдельного метода. Но, пожалуй, большую часть времени разработчики сталкиваются со сложностью на уровне отдельных классов и методов, поэтому общее, упрощенное правило управления сложностью я бы сформулировал следующим образом: «Чем меньше вещей нужно держать в голове, глядя на отдельный участок кода, тем меньше сложность этого кода». То есть программный код нужно организовывать так, чтобы можно было безопасно работать с отдельными фрагментами по очереди (по возможности не думая об остальных фрагментах).
Возможно вы скажете, что самое важное в написании ПО — это гибкость, возможность быстрого внесения изменений. Это действительно так, но, на мой взгляд, это является следствием из написанного выше. Если есть возможность работать с отдельными фрагментами кода, и код организован так, что, глядя на него, нужно держать в голове минимум другой информации, скорее всего внедрение изменений не будет болью.
Закон Деметры — один из рецептов, помогающих в борьбе со сложностью (а следовательно и с увеличением гибкости вашего кода).
Закон Деметры
Закон Деметры говорит нам о том же, о чем в детстве говорили родители: «Не разговаривай с незнакомцами». А разговаривать можно вот с кем:
— С методами самого объекта.
— С методами объектов, от которых объект зависит напрямую.
— С созданными объектами.
— С объектами, которые приходят в метод в качестве параметра.
— С глобальными переменными (что лично мне не кажется верным, так как глобальные переменные во многом увеличивают общую сложность)
Мы рассмотрим конкретный пример, а после сделаем выводы о том, каким образом закон Деметры помогает нам в написании хорошего кода.
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)
MzMz
16.01.2017 16:28Мне кажется класс Money как кортеж «валюта-сумма» лучше бы был иммутабельным
NikchukIvan
16.01.2017 16:47Согласен, но почему вы решили, что он mutable?
У класса нет методов, меняющих его внутреннее состояние, а методы вроде subtract задуманы возвращать новый объект Money (результат вычисления).
reforms
16.01.2017 21:59Можно вопрос — почему используется выражение moneyForProducts.subtract(actualSum), а не actualSum.subtract(moneyForProducts)?
gzhernov
19.01.2017 07:48А что делать если метод sell требует и Wallet и Money?
NikchukIvan
19.01.2017 07:52Если метод sell требует Wallet — это проблема дизайна.
Зависимость от Wallet в методе sell — это как раз то, от чего мы пытались уйти.
Метод нужен для осуществления продажи и должен зависеть от денег, но никак не от хранилища денег.gzhernov
20.01.2017 13:54И все же предположим он нужен и с дизайном все хорошо. Мой вопрос не о дизайне метода sell и не о самом методе. а о том как решить проблему если есть такая вот зависимость от Wallet и Money
NikchukIvan
20.01.2017 22:42Если методу sell для продажи понадобился Wallet, то с дизайном всё же не очень хорошо. Если вы считаете, что существует ситуация, при которой sell должен знать о Wallet, и это хороший дизайн, пожалуйста, приведите конкретный пример, и тогда я смогу ответить.
gzhernov
25.01.2017 11:43Вот пример. Бизнесаналитик говорит что в момент продажи необходимо помимо всего прочего вызвать еще и метод doSomethingImportant() из Wallet.
Как быть в этой ситуации? Как поступите?
areht
21.01.2017 13:36> Если метод sell требует Wallet — это проблема дизайна.
Если метод sell требует Money — это проблема дизайна.
Не знаю, почему у вас sell возвращает остаток денег в кошельке. Обычно при продаже формируется чек. Там есть не только деньги, но и счета списания и зачисления.
Где у вас защита от race condition?NikchukIvan
21.01.2017 14:08> Не знаю, почему у вас sell возвращает остаток денег в кошельке
Вы говорите о первом примере, который является примером того, как делать не нужно. Второй (более корректный пример) взаимодействует только с деньгами и возвращает сдачу.
> Обычно при продаже формируется чек
> Где у вас защита от race condition?
Цель этой статьи — показать пользу от применения закона в отдельно взятом, выдуманном случае. Этот случай недостаточно реален для того, чтобы он ровно в таком же виде возник у вас на проекте, но достаточно реален для того, чтобы из него можно было сделать какие-то выводы.
Я не ставил перед собой цели писать thread-safe пример, т.к. лишние строчки обработки race-condition'ов отвлекали бы читателя от основной мысли, которую я хотел донести. Я считаю, что это допустимо в рамках примера, хотя, возможно, следовало упомянуть об этом в начале статьи.lany
21.01.2017 17:15Всё же статья смотрелась бы серьёзнее, если бы пример был не выдуманный, а реальный. Меня, например, статья не убедила, что преобразования сделали код лучше.
areht
21.01.2017 21:13> Второй (более корректный пример) взаимодействует только с деньгами и возвращает сдачу.
У вас бизнес-процесс поменялся, что это стало «сдачей»? Сдача не с содержимого кошелька, а с одной из купюр из кошелька. У вас в примере не сдача.
Я напомню, что тред начался с «А что делать если метод sell требует и Wallet и Money?». И иметь и то и другое — нормально, если это не отдельно взятый выдуманный случай.
Если вы выдумываете плохие примеры — не надо с других спрашивать нормальные.NikchukIvan
21.01.2017 21:18Сложно что — либо возразить — пример действительно не очень удачный.
areht
21.01.2017 22:17Я это не к тому, что пример плох, а к тому, что вопрос gzhernov вполне корректен и на него надо бы ответить. Если есть что.
cheshirrrr
19.01.2017 14:52А вам не кажется что методу subtract самое место в классе Wallet?
Зачем объекту Seller знать сколько денег находится в кошельке и тем более пытаться самому их оттуда извлечь, если его дело — посчитать суммарную стоимость всех продуктов и сказать «кошельку», сколько тот должен заплатить.
Хотя, конечно, продавец скорее должен взаимодействовать с покупателем, а вовсе не с его кошельком напрямую.
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
когда априори, изменяться нечемуlany
21.01.2017 17:17В первом случае по-хорошему состояние изменяться должно, потому что метод называется
sell
. То что оно не меняется, очень странно.
Второй метод
sell
, который производит вычитание, по факту ничего не продаёт. Он мне нравится ещё меньше, чем первый, потому что непонятно, зачем он такой нужен.
А вообще спорные вопросы вроде изменяется ли передаваемый объект, стоит аккуратно расписывать в JavaDoc. Тогда всё будет хорошо.
reforms
21.01.2017 18:51Вы правы. Но я отдал предпочтение 2ому варианту исходя из следующих мыслей:
1) приводимый пример автором мне показался больше академическим, нежели боевым, иначе тогда нужно упомянуть вопросы логирования, рейс-кондишина, отката и др. 2) первый вариант показался бы странным, если объект Wallet был неизменяемым, если же он изменяемый, то зачем тогда возвращать остаток? Но все равно, теперь вы заставили меня сомневаться, что лучше?
j_wayne
Дополню альтернативной и спорной точкой зрения.
http://www.yegor256.com/2016/07/18/law-of-demeter.html
Patroskan
Очень интересно. В книжке, на которую ссылается статься, формулировка закона для ООП немножко другая, чем на википедии:
Тут речь идет про классы, а не объекты. Что, в общем случае, логично и несколько расширяет возможности вызова методов в коде. Например, можно утверждать, что любой класс зависит от стандартной библиотеки языка и тогда
a.b().length()
вполне ОК.Мне импонирует формулировка закона, где используется "знание про объект". В ООП это называется интерфейсом (и это паттерн, а не реализация, так что это применимо и в Ruby, JavaScript и прочих). Если смотреть с точки зрения публичного интерфейса объекта (класса объекта), а не отдельного объекта, то все становится немного проще.
А что самое интересное, эта книжка является самым ранним текстом, который мне удалось найти, упоминающим закон Деметры. Так что абстрагировались от классов, а потом и от объектов, и вообще добавили всякой отсебятины, уже куда позже.
И так всегда. Начинаешь искать концы, пока не окажется, что ООП — не ООП, REST — не REST и вообще.
Лучше почитать какую-то умную книжку, вникнуть в прекрасные идеи автора, и пользоваться потом собственной головой, а не придумывать инструкции механического набора кода на подобии "one-dot rule", как будто код набирает обезьяна.
Последняя строчка статьи правильно говорит :)
nuald
Оригинал Law of Demeter (статья 1988 года) находится тут: Object-Oriented Programming: An Objective Sense of Style. Там есть уточняющие формулировки закона для Lisp, C++ и Eiffel.
Я в свое время думал написать плагин для LLVM, который бы делал проверку этого закона, но предварительные эксперименты по внедрению такой проверки в стандарты кодирования провалились. Причиной тому была не чистая функциональность используемых языков программирования (в данном случае это были C++ и Python) — из-за того, что объекты можно передавать по ссылке, контролировать операции над ними не представлялось возможным (особенно, если это были библиотечные вызовы). В итоге мы использовали другие критерии, как например, тестируемость кода и ограничение cyclomatic complexity.
Vjatcheslav3345
А разве объект (внеся изменения в суперкласс) нельзя было сделать таким, чтобы он сам следил и "докладывал" о нарушениях "закона Деметры" в отношении себя — например, сообщал, что его передали по ссылке не тому, с кем ему можно общаться или наоборот — вызвал недопустимого объекта?
Shamov
По-моему, в ней нет ничего альтернативного. Лишь более правильная интерпретация, чем наиболее распространённая. В приведённом там примере использование метода
textOfLastPage()
не устраняет необходимость вызывающего кода знать внутреннее устройство вызываемого объекта. Ведь этот метод не может в какой-то момент начать возвращать текст первой страницы вместо последней… или вообще не текст, а число, обозначающее количество слов на странице. Для этого метод придётся переименовать, поскольку внутреннее устройство и реализация закодированы прямо в его названии.