Введение
На данный момент существует множество доказанных временем практик, помогающих разработчикам писать хорошо поддерживаемый, гибкий и удобно читаемый код. Закон Деметры — одна из таких практик.
Поскольку мы говорим об этом законе в контексте написания хорошего кода, перед его рассмотрением я хотел бы выразить свое понимание того, каким должен быть хороший код. Прочитав «Совершенный код» Макконнелла, я твёрдо уверовал в то, что главный технический императив разработки ПО — управление сложностью. Управление сложностью — довольно обширная тема, так как понятие сложности применимо на любом уровне проекта. Можно говорить о сложности в контексте общей архитектуры проекта, в контексте взаимосвязей модулей проекта, в контексте отдельного модуля, отдельного класса, отдельного метода. Но, пожалуй, большую часть времени разработчики сталкиваются со сложностью на уровне отдельных классов и методов, поэтому общее, упрощенное правило управления сложностью я бы сформулировал следующим образом: «Чем меньше вещей нужно держать в голове, глядя на отдельный участок кода, тем меньше сложность этого кода». То есть программный код нужно организовывать так, чтобы можно было безопасно работать с отдельными фрагментами по очереди (по возможности не думая об остальных фрагментах).
Возможно вы скажете, что самое важное в написании ПО — это гибкость, возможность быстрого внесения изменений. Это действительно так, но, на мой взгляд, это является следствием из написанного выше. Если есть возможность работать с отдельными фрагментами кода, и код организован так, что, глядя на него, нужно держать в голове минимум другой информации, скорее всего внедрение изменений не будет болью.
Закон Деметры — один из рецептов, помогающих в борьбе со сложностью (а следовательно и с увеличением гибкости вашего кода).
Закон Деметры
Закон Деметры говорит нам о том же, о чем в детстве говорили родители: «Не разговаривай с незнакомцами». А разговаривать можно вот с кем:
— С методами самого объекта.
— С методами объектов, от которых объект зависит напрямую.
— С созданными объектами.
— С объектами, которые приходят в метод в качестве параметра.
— С глобальными переменными (что лично мне не кажется верным, так как глобальные переменные во многом увеличивают общую сложность)
Мы рассмотрим конкретный пример, а после сделаем выводы о том, каким образом закон Деметры помогает нам в написании хорошего кода.
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() — формально тоже противоречит закону, но не противоречит идее коллекции.
— Руководствуйтесь здравым смыслом ;)