Извлечение метода против основополагающих допущений

27

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

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

Единственное решение, которое я видел для этого, - это whereпредложение в Haskell, которое позволяет вам определять небольшие функции, которые используются только в «родительской» функции. В основном это выглядит так:

len x y = sqrt $ (sq x) + (sq y)
    where sq a = a * a

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

Итак, мой вопрос - вы сталкиваетесь с этим, и вы вообще видите, что это проблема? Если да, то как вы обычно решаете эту проблему, особенно в «основных» языках ООП, таких как Java / C # / C ++?

Редактировать дубликаты: как уже заметили другие, уже есть вопросы, обсуждающие методы расщепления, и небольшие вопросы, которые являются однострочными. Я читаю их, и они не обсуждают проблему базовых предположений, которые могут быть получены из контекста вызывающей стороны (в примере выше, объект инициализируется). В этом суть моего вопроса, и именно поэтому мой вопрос отличается.

Обновление: если вы следили за этим вопросом и обсуждением под ним, вам может понравиться эта статья Джона Кармака по этому вопросу , в частности:

Помимо понимания фактического выполняемого кода, встроенные функции также имеют то преимущество, что они не позволяют вызывать функцию из других мест. Это звучит смешно, но в этом есть смысл. По мере того, как кодовая база растет с годами, у вас будет много возможностей использовать ярлык и просто вызывать функцию, которая выполняет только ту работу, которую, по вашему мнению, необходимо выполнить. Может существовать функция FullUpdate (), которая вызывает PartialUpdateA () и PartialUpdateB (), но в некоторых случаях вы можете осознать (или подумать), что вам нужно всего лишь выполнить PartialUpdateB (), и вы эффективны, избегая других Работа. Много-много ошибок проистекает из этого. Большинство ошибок являются результатом того, что состояние выполнения не совсем то, что вы думаете.

Макс Янков
источник
@gnat вопрос, на который вы ссылались, обсуждает, нужно ли вообще извлекать функции, пока я не подвергаю сомнению. Вместо этого я подвергаю сомнению самый оптимальный способ сделать это.
Макс Янков
2
@gnat Есть другие связанные вопросы, связанные с этим, но ни один из них не обсуждает тот факт, что этот код может опираться на конкретные предположения, которые действительны только в контексте вызывающего
Макс Янков
1
@ По моему опыту, это действительно так. Когда есть неприятные вспомогательные методы, как вы описываете, об этом заботится извлечение нового связного класса
комнат

Ответы:

29

Например, представьте, что метод инициализации разделен на несколько небольших: в контексте самого метода вы четко знаете, что состояние объекта по-прежнему недопустимо, но в обычном частном методе вы, вероятно, исходите из предположения, что объект уже инициализирован и является в действительном состоянии. Единственное решение, которое я видел для этого, это ...

Ваша забота обоснована. Есть другое решение.

Сделать шаг назад. Какова основная цель метода? Методы делают только одну из двух вещей:

  • Произведите ценность
  • Вызвать эффект

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

Вы заметили, что методы вызываются в «контексте». Что это за контекст?

  • Значения аргументов
  • Состояние программы вне метода

По сути, вы указываете: правильность результата метода зависит от контекста, в котором он вызывается .

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

По сути, вы указываете: когда я извлекаю блок кода в его собственный метод, я теряю контекстную информацию о предусловиях и постусловиях .

Решение этой проблемы состоит в том, чтобы сделать предварительные условия и постусловия явными в программе . Например, в C # вы можете использовать Debug.Assertили кодовые контракты для выражения предварительных условий и постусловий.

Например: раньше я работал над компилятором, который прошел несколько «этапов» компиляции. Сначала код будет лексирован, затем проанализирован, затем типы будут разрешены, затем иерархии наследования будут проверяться на циклы и так далее. Каждый бит кода был очень чувствителен к своему контексту; например, было бы катастрофическим задавать вопрос: «Этот тип обратим в этот тип?» если бы граф базовых типов еще не был ацикличен! Поэтому каждый бит кода четко документировал свои предварительные условия. В assertметоде, который проверял на конвертируемость типов, мы уже прошли проверку «ациклических базовых типов», и читателю стало ясно, где метод может быть вызван и где его нельзя вызвать.

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

  • создавать методы, которые полезны для их эффектов или их ценности, но не оба
  • сделать методы, которые были бы настолько «чистыми», насколько это возможно; «чистый» метод создает значение, которое зависит только от его аргументов, и не дает никакого эффекта. Это самый простой способ рассуждать, потому что «контекст», в котором они нуждаются, очень локализован.
  • минимизировать количество мутаций, которые происходят в состоянии программы; мутации - это точки, где код становится сложнее рассуждать о
Эрик Липперт
источник
+1 за ответ, который объясняет проблему с точки зрения предусловий / постусловий.
QuestionC
5
Я бы добавил, что часто возможно (и это хорошая идея!) Делегировать проверку предварительных и постусловий системе типов. Если у вас есть функция, которая принимает stringи сохраняет ее в базе данных, вы рискуете ввести SQL, если забудете ее очистить. Если, с другой стороны, ваша функция принимает a SanitisedString, и единственный способ получить a SantisiedString- это вызывать Sanitise, то вы исключили ошибки внедрения SQL в конструкцию. Я все чаще и чаще пытаюсь заставить компилятор отклонять некорректный код.
Бенджамин Ходжсон
+1 Важно отметить, что разделение большого метода на более мелкие фрагменты имеет смысл: обычно это бесполезно, если только предварительные условия и постусловия не являются более расслабленными, чем они были бы изначально, и в результате вам может понадобиться оплатить стоимость путем повторного выполнения чеков, которые вы в противном случае уже сделали бы. Это не совсем «бесплатный» процесс рефакторинга.
Мердад
"Что это за контекст?" просто чтобы уточнить, я в основном имел в виду частное состояние объекта, для которого вызывается этот метод. Я думаю, это входит во вторую категорию.
Макс Янков
Это отличный и заставляющий задуматься ответ, спасибо. (Конечно, нельзя сказать, что другие ответы плохие). Я пока не буду отмечать вопрос как ответивший, потому что мне действительно нравится обсуждение здесь (и оно имеет тенденцию прекращаться, когда ответ помечается как ответ), и мне нужно время, чтобы обработать его и подумать об этом.
Макс Янков
13

Я часто вижу это и согласен с тем, что это проблема. Обычно я решаю это путем создания объекта метода : нового специализированного класса, членами которого являются локальные переменные из исходного, слишком большого метода.

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

Килиан Фот
источник
Мне очень нравится эта идея, чем больше я об этом думаю. Это может быть закрытый класс внутри публичного или внутреннего класса. Вы не загромождаете свое пространство имен классами, которые вам нужны только локально, и это способ отметить, что это «помощники конструктора» или «помощники синтаксического анализа» или что-то еще.
Майк поддерживает Монику
Недавно я попал в ситуацию, которая была бы идеальной для этого с точки зрения архитектуры. Я написал программный рендерер с классом рендеринга и публичным методом рендеринга, который имел МНОГО контекста, который использовался для вызова других методов. Я подумывал о создании отдельного класса RenderContext для этого, однако было просто невероятно расточительно распределять и освобождать этот проект в каждом кадре. github.com/golergka/tinyrenderer/blob/master/src/renderer.h
Макс Янков,
6

Многие языки позволяют вам использовать такие функции, как Haskell. Java / C # / C ++ на самом деле являются относительными выбросами в этом отношении. К сожалению, они настолько популярны , что люди приходят , чтобы думать, «Он должен быть плохой идеей, иначе мой любимый„основной“язык позволил бы его.»

Java / C # / C ++ в основном считают, что класс должен быть единственной группой методов, которая вам когда-либо понадобится. Если у вас так много методов, что вы не можете определить их контексты, есть два основных подхода: сортировать их по контексту или разбивать по контексту.

Сортировка по контексту - это одна из рекомендаций, сделанных в Чистом коде , где автор описывает шаблон «TO абзацев». По сути, это помещает ваши вспомогательные функции сразу после вызывающей их функции, поэтому вы можете читать их как абзацы в газетной статье, получая больше подробностей по мере прочтения. Я думаю, что в своих видео он даже отступает от них.

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

Карл Билефельдт
источник
Вложенные функции ... разве этого не достигают лямбда-функции в C # (и Java 8)?
Артуро Торрес Санчес
Я думал больше как замыкание, определенное именем, как в этих примерах на python . Лямбды не самый ясный способ сделать что-то подобное. Они больше для коротких выражений, таких как предикат фильтра.
Карл Билефельдт
Эти примеры Python, безусловно, возможны в C #. Например, факториал . Они могут быть более многословными, но они возможны на 100%.
Артуро Торрес Санчес
2
Никто не сказал, что это невозможно. ОП даже упомянул использование лямбд в своем вопросе. Просто если вы извлекаете метод для удобства чтения, было бы хорошо, если бы он был более читабельным.
Карл Билефельдт
Ваш первый абзац, кажется, подразумевает, что это невозможно, особенно с вашей цитатой: «Это должна быть плохая идея, иначе мой любимый« основной »язык позволил бы это».
Артуро Торрес Санчес
4

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

Методы извлечения это хорошо, но в некоторой степени. Я всегда стараюсь задавать себе эти вопросы при проверке или перед написанием кода:

  • Этот код используется только этим классом / функцией? останется ли он таким же в будущем?
  • Если мне нужно будет отключить некоторые конкретные реализации, могу ли я сделать это легко?
  • Могут ли другие разработчики из моей команды понять, что сделано в этой функции?
  • Этот же код используется где-то еще в этом классе? Вы должны избегать дублирования почти во всех случаях.

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

Томер Блу
источник
1

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

Я не понимал, насколько серьезной была эта проблема, пока не принял ECS, который поощрял большие, зациклившиеся системные функции (единственными системами, имеющими функции) и зависимости, направленные на необработанные данные , а не абстракции.

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

В отличие от Джона Кармака, моей самой большой проблемой с этими кодовыми базами была не производительность, так как у меня никогда не было такого сверхжесткого требования к задержке игровых движков AAA, и большинство наших проблем с производительностью относились больше к пропускной способности. Конечно, вы также можете начать усложнять оптимизацию «горячих точек», когда работаете в более узких и более узких рамках более мелких и младших функций и классов, не мешая этой структуре (требуя, чтобы вы слили все эти маленькие кусочки обратно к чему-то большему, прежде чем вы сможете даже начать эффективно заниматься этим).

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

Теперь мне нравятся мои 80-строчные функции с большими задницами здесь и там, при условии, что они по-прежнему выполняют исключительную и четкую ответственность и не имеют 8 уровней вложенных блоков. Они приводят к ощущению, что в системе меньше вещей для тестирования и понимания, даже если меньшие, нарезанные кубиками версии этих больших функций были только частными деталями реализации, которые никто другой не мог бы вызвать ... все же, каким-то образом, обычно создается впечатление, что по всей системе происходит меньше взаимодействий. Мне даже нравится какое-то очень скромное дублирование кода, если это не сложная логика (скажем, всего 2-3 строки кода), если это означает меньше функций. Мне нравится рассуждения Кармака о том, что эта функциональность невозможна для вызова в другом месте исходного файла. Там'

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

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


источник
0

Я не думаю, что это большая проблема, но я согласен, что это хлопотно. Обычно я просто помещаю помощника сразу после его бенефициара и добавляю суффикс «Помощник». Этот плюс privateспецификатор доступа должен прояснить его роль. Если есть какой-то инвариант, который не сохраняется, когда вызывается помощник, я добавляю комментарий во помощник.

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

Вы уже упоминали другое решение - определите помощника внутри главной функции. Это может быть несколько необычной идиомой в некоторых языках, но я не думаю, что это будет сбивать с толку (если ваши коллеги не смущены лямбдами в целом). Это работает, только если вы можете легко определить функции или функционально-подобные объекты. Я бы не стал пробовать это в Java 7, например, так как анонимный класс требует введения 2 уровней вложенности даже для самой маленькой «функции». Это как можно ближе к предложению letили where, как вы можете получить; Вы можете ссылаться на локальные переменные перед определением, и вспомогательный объект не может быть использован вне этой области.

Doval
источник