Закон Деметры в дизайне API для C ++

В своей книге «Дизайн API для C ++» Мартин Редди подробно описывает закон Деметры. В частности, он заявляет, что:

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

Он подкрепляет свое утверждение цепочкой вызовов функций вроде

Func()
{
    [...]
    m_A.GetObjectB().DoSomething();
    [...]
}

Вместо этого он предлагает передать B в качестве аргумента функции, например:

Func(const ObjectB &B)
{
    [...]
    B.DoSomething();
    [...]
}

Мой вопрос: почему последний пример дает больше слабосвязанных классов, чем первый?


person Korchkidu    schedule 05.07.2013    source источник
comment
Возможно, связано: programmers.stackexchange.com/q/203684/5409   -  person Richard J. Ross III    schedule 05.07.2013
comment
Мне это кажется очевидной чепухой, способ писать запутанный и трудный для чтения код, обеспечивая при этом безопасность работы, потому что другим становится труднее поддерживать ваш код.   -  person Dietrich Epp    schedule 05.07.2013
comment
Я согласен с @DietrichEpp, что закон Деметры - плохая идея. Я не стану отрицать, что закон Деметса предусматривает развязку, которая, в свою очередь, может помочь в создании поддерживаемого кода. Но в большинстве случаев я считаю разделение не единственным и не самым эффективным способом создания поддерживаемого кода. А делать из этого такую ​​догму еще менее полезно.   -  person    schedule 05.07.2013
comment
Я бы не сказал, что закон - плохая идея, плохая идея - попытаться сделать его законом. Им следовало назвать это идеей Деметры просто для того, чтобы указать, что это, как и почти все концепции в программировании, просто руководство к чему-то, что в некоторых случаях является хорошим делом. Но не всегда.   -  person stijn    schedule 05.07.2013
comment
Итак, вы не доверяете A вести себя хорошо с B. Но это означает, что позже вместо того, чтобы A обрабатывать возможный сбой с B, вы должны теперь обрабатывать сбои с A и B, потому что у вас есть член A и параметр функции B вместо того, чтобы просто обрабатывать сбои с участником А. Это ужасная идея, на мой взгляд.   -  person    schedule 06.07.2013


Ответы (5)


Часто используется аналогия (в том числе на странице Википедии, как я заметил): вы просите собаку выгуливать - вы спрашиваете собаку, вы не просите доступа к ее ногам, а затем просите ее ноги выгуливать.

Лучше попросить собаку выгуливать, потому что однажды вы можете захотеть собаку с чем-то другим, кроме ног.

В вашем конкретном примере реализация m_A может перестать зависеть от экземпляра B.


РЕДАКТИРОВАТЬ: поскольку некоторые люди хотят дальнейшего изложения, позвольте мне попробовать это:

Если объект X содержит оператор m_A.GetObjectB().DoSomething(), тогда X должен знать:

  1. этот m_A имеет экземпляр объекта B, представленного через GetObject(); и
  2. что объект B имеет метод DoSomething().

Итак, X необходимо знать интерфейсы A и B, а A всегда иметь возможность продавать B.

И наоборот, если X просто нужно было сделать m_A.DoSomething(), то все, что ему нужно знать, это:

  1. что m_A имеет метод DoSomething().

Таким образом, закон способствует разъединению, потому что X теперь полностью отделен от B - ему не нужно ничего знать об этом классе - и меньше знаний о A - он знает, что A может достичь DoSomething(), но ему больше не нужно знать, делает ли он это сам или просит ли это сделать кто-то еще.

На практике закон часто не используется, потому что он обычно означает просто написание сотен функций-оболочек, таких как A::DoSomething() { m_B.DoSomething(); }, а формальная семантика вашей программы часто явно диктует, что A будет иметь B, поэтому вы не так сильно раскрываете детали реализации, предоставляя GetObjectB() поскольку вы просто выполняете контракт этого объекта с системой в целом.

Первый пункт также можно использовать, чтобы утверждать, что закон увеличивает связь. Предположим, у вас изначально было m_A.GetObjectB().GetObjectC().GetObjectD().DoSomething(), и вы свернули его до m_A.DoSomething(). Это означает, что, поскольку C знает, что D реализует DoSomething(), C должен это реализовать. Затем, поскольку B теперь знает, что C реализует DoSomething(), B должен это реализовать. И так далее. В конце концов, вам нужно A реализовать DoSomething(), потому что D это делает. Итак, A в конечном итоге вынужден действовать определенным образом, потому что D действует определенным образом, тогда как раньше он мог ничего не знать о D.

Во-первых, сравнимая ситуация - методы Java, традиционно объявляющие исключения, которые они могут генерировать. Это означает, что они также должны перечислить исключения, которые все, что они вызывают, может вызвать, если они этого не поймают. Таким образом, каждый раз, когда листовой метод добавляет еще одно исключение, вам нужно пройти вверх по дереву вызовов, добавляя это исключение к целой группе списков. Так что хорошая идея развязки приводит к бесконечной бумажной волоките.

По второму пункту, я думаю, мы заблудились в дебатах «есть» или «есть». «Имеет» - это очень естественный способ выразить некоторые объектные отношения и догматически скрыть это за фасадом типа «У меня есть ключи от шкафчика, поэтому, если вы хотите, чтобы ваш шкафчик открылся, просто подойдите и спросите меня, и я его открою». разговоры просто затмевают задачу.

person Tommy    schedule 05.07.2013
comment
Но вы бы не попросили библиотеку прочитать книгу. Вы бы попросили книгу в библиотеке, а потом прочитали бы ее сами. Может быть, завтра вы вместо этого захотите купить книгу, так что вы пойдете в книжный магазин, купите книгу и прочтете ее. Для каждого выбора стиля существует аналогия, которая его поддерживает, поэтому аналогии не являются аргументом в пользу компьютерного программирования. - person Dietrich Epp; 05.07.2013
comment
Во втором случае нужно отдать лапки собаки. Это тоже не кажется отличным;) - person Korchkidu; 05.07.2013
comment
В вашем конкретном примере реализация m_A может перестать зависеть от экземпляра B.Если m_A имеет экземпляр B, это потому, что он НУЖЕН в первую очередь, а не потому, что Func потребуется A для вызова B (поэтому я добавляю его к А). - person Korchkidu; 05.07.2013
comment
@DietrichEpp, поэтому Закон Деметры не очень хорошо принят; Тем не менее, я считаю свой ответ точным ответом на поставленный вопрос. - person Tommy; 05.07.2013
comment
@Korchkidu, если у m_A есть экземпляр B, тогда ему нужен один в текущей реализации. Он может не понадобиться в будущем. Например. в моей машине есть бензобак, потому что он нужен не потому, что я купил его, чтобы он работал как емкость. Но у будущей машины может не быть бензобака. Поскольку нет развязки, если моя машина решит питаться другим способом, мне нужно изменить способ заправки. Если бы у автомобилей было агентство, как у объектов, тогда было бы лучше, если бы я мог просто сказать: «Хорошо, машина, заправляйся». - person Tommy; 05.07.2013
comment
@Tommy: Это не обязательно тот случай, когда B является просто частью текущей реализации A, но он может быть частью интерфейса A. Если B является просто частью реализации, тогда он никогда не должен был отображаться в первой место. Если это часть интерфейса, ничего страшного. Например, в моей машине есть пассажиры не только потому, что пассажиры необходимы, но и потому, что пассажиры являются частью интерфейса автомобиля. - person Dietrich Epp; 05.07.2013
comment
Чтобы расшириться, могу попросить пассажиров платить за бензин. Это не часть интерфейса автомобиля, потому что автомобиль не привязан к деньгам и платежам. И я не собираюсь вынимать деньги из кошелька моего друга, потому что это не часть интерфейса моего друга (возможно, он держит деньги на своем месте, мне все равно). - person Dietrich Epp; 05.07.2013
comment
@DietrichEpp, но эти вещи часто раскрываются именно для того, чтобы избежать обертки, которую подразумевает Закон Деметры. Я думаю, вы возражаете против шаблона. Я думаю, что эта дискуссия нарушает принцип «имеет» против «есть»; с Законом Деметры вы фактически притворяетесь, что всегда находитесь в лагере «есть», демонстрируя все составные функции, даже если они реализованы внутри как «имеет». - person Tommy; 06.07.2013
comment
Вы правы, я не согласен с закономерностью, потому что считаю этот ответ неполным и односторонним. Речь идет о мелких и глубоких взаимосвязях в интерфейсах между объектами, и если вы решите сделать интерфейсы более мелкими, сложность интерфейса возрастет. Для сравнения, на странице Википедии есть более подробное объяснение недостатков подхода Закона Деметры, и я рекомендую прочитать его. - person Dietrich Epp; 06.07.2013
comment
@DietrichEpp, возникает вопрос: почему последний пример дает больше слабосвязанных классов, чем первый? в соответствии с акцентом StackOverflow на конкретных, сфокусированных вопросах, а не на обсуждениях. В результате я не собирался давать заключение по закону или даже пытаться объективно выдвигать аргументы «за» и «против». Вы действительно думаете, что этот ответ дает неполный ответ на этот вопрос? - person Tommy; 06.07.2013
comment
Позвольте мне перефразировать вещи в этом узком контексте: dog.getLegs().walk() вводит связь между потребителем dog и тем, как реализован walk(), поэтому мы предоставляем dog.walk() интерфейс. Однако library.readBook("Ulysses") вводит связь между library и book, поэтому мы расширяем его до library.find("Ulysses").read(), поэтому library не нужно знать, что вы можете делать с книгой, и можно просто рассматривать ее как общий объект. Я говорю именно об этом за / против и почему этот ответ о развязке неполон. - person Dietrich Epp; 06.07.2013
comment
@DietrichEpp, но библиотека уже знает о книгах, потому что они у нее есть. Я считаю, что закон всегда увеличивает развязку, но часто бесполезен и в любом случае практически не работает на практике (и соответственно обновил свой ответ) из-за проблем, связанных с отсутствием развязки. В любом случае, я добавил подробности к своему ответу, поскольку, если вы считаете необходимым ответить на исходный вопрос, то он обязательно актуален. - person Tommy; 06.07.2013
comment
@Tommy: кажется, твое редактирование смешивает m_a и B, я думаю. Второй пример на самом деле вызывает B.DoSomething (). Я считаю, что развязка заключается в том, чтобы не использовать A :: GetObjectB (). - person Korchkidu; 06.07.2013

Чтобы напрямую ответить на ваш вопрос:

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

То есть в первом случае существует связь между классом m_A и Func, во втором случае - нет. Если интерфейс этого класса должен когда-либо измениться, чтобы не иметь GetObjectB(), но, например, чтобы иметь GetFirstObjectB() и GetSecondObjectB(), в первом случае вам придется переписать Func для вызова соответствующей функции замены (и, возможно, даже добавить некоторую логику, которую следует вызывать, возможно, на основе дополнительного аргумента функции), а во второй версии вы может оставить функцию как есть и позволить пользователям Func заботиться о том, как получить этот объект типа ObjectB.

person celtschk    schedule 05.07.2013
comment
Очень четкое объяснение. Теперь я ясно вижу преимущество развязки примера 2 по сравнению с примером 1. Спасибо! - person Korchkidu; 06.07.2013

Разница становится еще больше, если посмотреть на модульные тесты.

Предположим, что DoSomething() имеет побочный эффект, который вы не хотите иметь в своем тестовом коде, потому что было бы дорого или раздражающе моделировать, например, доступ к базе данных или сетевое взаимодействие.

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

Во втором случае вы просто вызываете Func() с поддельным экземпляром ObjectB, что значительно упрощает тест.

person confusopoly    schedule 05.07.2013

Он более гибкий к изменениям. Представьте, что m_A - это экземпляр объекта A, разработанный программистом Бобом. Если он решит внести изменения в свой код, чтобы A больше не имел метода для возврата объекта типа B, то Алисе, разработчику Func, также придется изменить свой код. Обратите внимание, что у вас нет этой проблемы с последним фрагментом кода.

В разработке программного обеспечения этот тип связи приводит к так называемым неортогональным проектам - типам проектов, в которых вы где-то меняете локальную часть кода и вам нужно менять части также и в других местах.

person Daniel Martín    schedule 05.07.2013
comment
Что ж, если при разработке вашего API Боб решил предоставить доступ к B из A, то явно неправильно менять API после его выпуска. Собственно, в этом и заключается суть этой книги. Кроме того, добавление B в качестве аргумента добавляет сложности API, если Func на самом деле является его частью. Более того, я не понимаю, почему он не был бы ортогональным. Не могли бы вы уточнить этот момент? - person Korchkidu; 05.07.2013

Что ж, я думаю, должно быть очевидно, почему объединение функций в цепочку - это плохо, так как при этом становится труднее поддерживать код. В верхнем примере Func() - уродливая функция, потому что кажется, что она будет вызываться просто как

Func();

По сути, ничего не говорит вам о функции. Второй предложенный метод вызывает функцию с переданным ему B, что не только делает его более читаемым, но и означает, что вы можете написать Func() для других классов, не переименовывая его (поскольку, если он не принимает никаких параметров, вы не можете переписать его для другого класс). Это говорит вам, что Func() будет делать аналогичные вещи с объектом, даже если класс отличается.

Чтобы ответить на последнюю часть вашего вопроса, слабая связь достигается, потому что первый пример подразумевает, что вы должны получить от B до A, которые объединяют классы вместе, второй пример является более общим и подразумевает, что B может поступать откуда угодно.

person aaronman    schedule 05.07.2013
comment
Обратной стороной является то, что ваш API менее чистый и требует от клиента выполнения более бесполезной работы. - person Korchkidu; 05.07.2013
comment
Потому что для этого нужно больше аргументов. Чем меньше, тем лучше при разработке API. - person Korchkidu; 05.07.2013
comment
Совершенно не согласен с этим, функция должна иметь правильное количество аргументов, уменьшение количества аргументов должно выполняться путем инкапсуляции, а не так, как это делается в вашем примере - person aaronman; 05.07.2013
comment
Не говоря уже о том, видели ли вы, сколько аргументов принимают некоторые функции и методы в классах C ++. - person aaronman; 05.07.2013
comment
Больше аргументов, больше методов, больше классов, как указано на соответствующей странице Википедии. Меня учили стремиться к минимальному API во время проектирования ... Что касается проблемы связи, единственная удаленная связь, которую я вижу, - это не вызов метода A :: GetObjectB (). Однако, если этот метод есть в API, это потому, что он мне очень НУЖЕН в API. Не потому, что где-то мне нужно было получить четверку от А. - person Korchkidu; 05.07.2013
comment
Я согласен, что развязка минимальна, но если вы не понимаете причины, по которым второй пример является лучшим кодом, я не думаю, что смогу вам помочь. - person aaronman; 05.07.2013
comment
На самом деле из того, что нам здесь дано, невозможно сказать, какой из них лучше. Обратите внимание, что m_A намекает на переменную-член, что означает, что m_A, скорее всего, является скрытой деталью реализации. Поэтому не исключено, что получение ObjectB от него также является деталью реализации, и раскрытие этого пользователю класса (чтобы он мог передать ObjectB в функцию), следовательно, увеличит связь, потому что теперь клиенты будут зависеть от деталей реализации класса, а именно от того, что в какой-то момент получается и используется ObjectB. - person celtschk; 06.07.2013
comment
@aaronman: утверждать, что один лучше другого, просто неправильно. По крайней мере, есть некоторые недостатки (указанные здесь: en.wikipedia.org/wiki/Law_Of_Demeter#Disadvantages). В зависимости от того, что для вас важно, я думаю, что одно решение может быть лучше другого. - person Korchkidu; 06.07.2013
comment
@Korchkidu причина, по которой я говорю, что второй пример почти наверняка лучший код, состоит в том, что первый результат вызывает вызов функции, такой как Func();, вызов статической функции Func сам по себе без параметров сбивает с толку. С другой стороны, если у него есть параметр, более очевидно, что происходит - person aaronman; 06.07.2013
comment
@celtschk, вы говорите, что в обоих примерах существует связь, то, что я пытался объяснить, заключается в том, что первый пример - это уродливый код из-за цепочки вызовов функций - person aaronman; 06.07.2013
comment
Я говорю, что без контекста невозможно сказать, какая из них хуже, потому что вторая версия, создавая менее непосредственную связь, вполне может вызвать худшую связь в контексте, если Func является частью общедоступного интерфейса, но GetObjectB - это деталь реализации. Тогда единственный способ для клиентов вызвать вторую версию будет заключаться в том, что класс предоставляет клиентам детали реализации GetObjectB, что создает связь между всеми клиентами этого класса и детали реализации ObjectB, что хуже, чем связь, создаваемая первая версия Func. - person celtschk; 06.07.2013