Extração de método x premissas subjacentes

27

Quando eu divido métodos grandes (ou procedimentos ou funções - essa pergunta não é específica para OOP, mas como eu trabalho em idiomas OOP 99% das vezes, é com a terminologia com a qual me sinto mais confortável) em muitos pequenos , Muitas vezes me sinto descontente com os resultados. Torna-se mais difícil argumentar sobre esses métodos pequenos do que quando eram apenas blocos de código no grande, porque quando os extraio, perco muitas suposições subjacentes que vêm do contexto do chamador.

Mais tarde, quando olho para esse código e vejo métodos individuais, não sei imediatamente de onde eles são chamados e penso neles como métodos particulares comuns que podem ser chamados de qualquer lugar do arquivo. Por exemplo, imagine um método de inicialização (construtor ou não) dividido em uma série de pequenos: no contexto do próprio método, você sabe claramente que o estado do objeto ainda é inválido, mas em um método privado comum você provavelmente parte da suposição de que o objeto já foi inicializado e está em um estado válido.

A única solução que vi para isso é a wherecláusula em Haskell, que permite definir pequenas funções que são usadas apenas na função "pai". Basicamente, fica assim:

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

Mas outros idiomas que eu uso não têm nada parecido com isso - o mais próximo é definir um lambda em um escopo local, o que provavelmente é ainda mais confuso.

Então, minha pergunta é - você encontra isso e vê que isso é um problema? Se o fizer, como você normalmente o soluciona, principalmente em linguagens OOP "mainstream", como Java / C # / C ++?

Edite sobre duplicatas: como outros observaram, já existem perguntas sobre métodos de divisão e pequenas perguntas que são de uma linha. Eu os li e eles não discutem a questão das suposições subjacentes que podem ser derivadas do contexto do chamador (no exemplo acima, o objeto sendo inicializado). Esse é o ponto da minha pergunta, e é por isso que minha pergunta é diferente.

Atualização: se você seguiu esta pergunta e discussão abaixo, poderá apreciar este artigo de John Carmack sobre o assunto , em particular:

Além da conscientização do código real sendo executado, as funções embutidas também têm o benefício de não possibilitar a chamada da função de outros lugares. Isso parece ridículo, mas há um ponto nisso. À medida que a base de código cresce ao longo dos anos de uso, haverá muitas oportunidades para usar um atalho e chamar uma função que faz apenas o trabalho que você acha que precisa ser feito. Pode haver uma função FullUpdate () que chama PartialUpdateA () e PartialUpdateB (), mas em alguns casos particulares você pode perceber (ou pensar) que você só precisa fazer PartialUpdateB () e está sendo eficiente evitando a outra. trabalhos. Muitos e muitos erros resultam disso. A maioria dos erros é resultado do estado de execução não ser exatamente o que você pensa que é.

Max Yankov
fonte
@ A pergunta que você vinculou discute se deve ou não extrair funções, enquanto eu não a questiono. Em vez disso, questiono o método mais ideal para fazê-lo.
precisa
2
@gnat, existem outras questões relacionadas vinculadas a partir daí, mas nenhuma delas discute o fato de que esse código pode se basear em suposições específicas válidas apenas no contexto do chamador.
precisa
1
@Doval na minha experiência, ele realmente faz. Quando há problemáticos métodos auxiliares pendurados em torno de como você descreve, extrair uma nova coesa classe cuida disso
mosquito

Respostas:

29

Por exemplo, imagine um método de inicialização dividido em uma série de pequenos: no contexto do próprio método, você sabe claramente que o estado do objeto ainda é inválido, mas em um método privado comum, você provavelmente parte da suposição de que o objeto já foi inicializado e é em um estado válido. A única solução que vi para isso é ...

Sua preocupação é bem fundamentada. Existe outra solução.

Dê um passo para trás. Qual é o objetivo fundamental de um método? Os métodos fazem apenas uma de duas coisas:

  • Produzir um valor
  • Causar um efeito

Ou, infelizmente, ambos. Eu tento evitar métodos que fazem as duas coisas, mas muitas fazem. Digamos que o efeito produzido ou o valor produzido seja o "resultado" do método.

Você nota que os métodos são chamados em um "contexto". Qual é esse contexto?

  • Os valores dos argumentos
  • O estado do programa fora do método

Essencialmente, o que você está apontando é: a correção do resultado do método depende do contexto em que é chamado .

Chamamos as condições necessárias antes que o corpo de um método comece para que o método produza um resultado correto como pré-condições , e chamamos as condições que serão produzidas após o corpo do método retornar suas pós-condições .

Então, essencialmente, o que você está apontando é: quando extraio um bloco de código em seu próprio método, estou perdendo informações contextuais sobre as pré-condições e pós-condições .

A solução para esse problema é tornar explícitas as pré-condições e pós-condições no programa . Em C #, por exemplo, você pode usar o Debug.AssertCode Contracts para expressar pré-condições e pós-condições.

Por exemplo: eu costumava trabalhar em um compilador que passava por vários "estágios" da compilação. Primeiro o código seria lexado, depois analisado, os tipos resolvidos, as hierarquias de herança verificadas quanto a ciclos e assim por diante. Cada parte do código era muito sensível ao seu contexto; seria desastroso, por exemplo, perguntar "esse tipo é conversível nesse tipo?" se ainda não se sabia que o gráfico dos tipos de base era acíclico! Portanto, todo pedaço de código documentou claramente suas pré-condições. No assertmétodo que verificou a conversibilidade do tipo, já tínhamos passado a verificação "tipos básicos de base acílica" e ficou claro para o leitor onde o método poderia ser chamado e onde não poderia ser chamado.

É claro que existem várias maneiras pelas quais o bom design de métodos atenua o problema que você identificou:

  • crie métodos que sejam úteis para seus efeitos ou valor, mas não ambos
  • crie métodos tão "puros" quanto possível; um método "puro" produz um valor que depende apenas de seus argumentos e não produz efeito. Esses são os métodos mais fáceis de raciocinar, porque o "contexto" de que eles precisam é muito localizado.
  • minimizar a quantidade de mutação que ocorre no estado do programa; mutações são pontos em que o código fica mais difícil de raciocinar sobre
Eric Lippert
fonte
+1 por ser a resposta que explica o problema em termos de pré-condições / pós-condições.
QuestionC
5
Eu acrescentaria que muitas vezes é possível (e é uma boa idéia!) Delegar a verificação das condições pré e pós no sistema de tipos. Se você tem uma função que pega a stringe salva no banco de dados, corre o risco de injeção de SQL se esquecer de limpá-la. Se, por outro lado, sua função usa ae SanitisedStringa única maneira de obter uma SantisiedStringé chamando Sanitise, você descartou os erros de injeção SQL por construção. Cada vez mais me pego procurando maneiras de fazer o compilador rejeitar código incorreto.
Benjamin Hodgson
+1 Uma coisa importante a ser observada é que existe um custo para dividir um método grande em partes menores: normalmente não é útil, a menos que as condições e pós-condições sejam mais relaxadas do que teriam sido originalmente, e você pode acabar tendo que pague o custo refazendo os cheques que você já teria feito. Não é um processo de refatoração completamente "gratuito".
Mehrdad 24/03
"Qual é esse contexto?" apenas para esclarecer, eu quis dizer principalmente o estado privado do objeto em que esse método é chamado. Eu acho que está incluído na segunda categoria.
precisa
Esta é uma resposta excelente e instigante, obrigado. (Para não dizer que outras respostas são ruins, é claro). Ainda não marcarei a pergunta como respondida, porque realmente gosto da discussão aqui (e ela tende a cessar quando a resposta é marcada como respondida) e preciso de tempo para processá-la e pensar sobre ela.
precisa
13

Costumo ver isso e concordo que é um problema. Normalmente, eu resolvo isso criando um objeto de método : uma nova classe especializada cujos membros são as variáveis ​​locais do método original, muito grande.

A nova classe tende a ter um nome como 'Exportador' ou 'Tabulação' e é passada todas as informações necessárias para executar essa tarefa específica a partir de um contexto maior. Então, é livre definir trechos de código auxiliar ainda menores que não correm o risco de serem usados ​​para nada além de tabular ou exportar.

Kilian Foth
fonte
Eu realmente gosto dessa idéia, quanto mais penso nisso. Pode ser uma classe privada dentro da classe pública ou interna. Você não bagunça seu espaço de nome com classes com as quais se preocupa muito localmente, e é uma maneira de marcar que esses são "ajudantes de construtor" ou "ajudantes de análise" ou o que for.
Mike Suporta Monica 31 /
Recentemente, eu estava em uma situação que seria ideal para isso da perspectiva da arquitetura. Escrevi um renderizador de software com uma classe de renderizador e um método de renderização público, que tinha muito contexto que costumava chamar outros métodos. Eu pensei em criar uma classe RenderContext separada para isso, no entanto, parecia um desperdício enorme alocar e desalocar esse projeto a cada quadro. github.com/golergka/tinyrenderer/blob/master/src/renderer.h
Max Yankov
6

Muitos idiomas permitem aninhar funções como Haskell. Java / C # / C ++ são realmente discrepantes relativos a esse respeito. Infelizmente, eles são tão populares que as pessoas pensam: " Tem que ser uma má idéia, caso contrário, minha linguagem favorita 'mainstream' permitiria".

Java / C # / C ++ basicamente acha que uma classe deve ser o único agrupamento de métodos que você precisa. Se você possui tantos métodos que não consegue determinar seus contextos, há duas abordagens gerais a serem adotadas: classifique-os por contexto ou divida-os por contexto.

A classificação por contexto é uma recomendação feita no Código Limpo , em que o autor descreve um padrão de "parágrafos TO". Isso basicamente coloca as funções auxiliares imediatamente após a função que as chama, para que você possa lê-las como parágrafos em um artigo de jornal, obtendo mais detalhes à medida que lê. Eu acho que nos vídeos dele ele até recua.

A outra abordagem é dividir suas classes. Isso não pode ser levado muito longe, devido à necessidade irritante de instanciar objetos antes que você possa chamar qualquer método para eles e problemas inerentes ao decidir qual das várias classes minúsculas deve possuir cada parte dos dados. No entanto, se você já identificou vários métodos que realmente se encaixam apenas em um contexto, eles provavelmente são um bom candidato a considerar colocar em sua própria classe. Por exemplo, a inicialização complexa pode ser feita em um padrão criacional como o construtor.

Karl Bielefeldt
fonte
Aninhando funções ... não é isso que as funções lambda alcançam em C # (e Java 8)?
Arturo Torres Sánchez
Eu estava pensando mais como um fechamento definido com um nome, como esses exemplos de python . Lambdas não são a maneira mais clara de fazer algo assim. Eles são mais para expressões curtas como um predicado de filtro.
Karl Bielefeldt
Esses exemplos de Python são certamente possíveis em C #. Por exemplo, o fatorial . Eles podem ser mais detalhados, mas são 100% possíveis.
Arturo Torres Sánchez
2
Ninguém disse que não era possível. O OP até mencionou usar lambdas em sua pergunta. Apenas se você extrair um método para facilitar a leitura, seria bom se fosse mais legível.
Karl Bielefeldt
Seu primeiro parágrafo parece sugerir que não é possível, especialmente com a sua citação: "Tem que ser uma má idéia, caso contrário, minha linguagem favorita 'mainstream' permitiria".
Arturo Torres Sánchez
4

Eu acho que a resposta na maioria dos casos é contexto. Como desenvolvedor, escrevendo código, você deve presumir que seu código será alterado no futuro. Uma classe pode ser integrada a outra classe, pode substituir seu algoritmo interno ou pode ser dividida em várias classes para criar abstração. Essas são coisas que os desenvolvedores iniciantes geralmente não levam em consideração, causando a necessidade de soluções alternativas complicadas ou revisões completas mais tarde.

Extrair métodos é bom, mas até certo ponto. Eu sempre tento me fazer essas perguntas ao inspecionar ou antes de escrever o código:

  • Esse código é usado apenas por essa classe / função? permanecerá o mesmo no futuro?
  • Se precisar mudar parte da implementação concreta, posso fazê-lo facilmente?
  • Outros desenvolvedores da minha equipe podem entender o que é feito nessa função?
  • O mesmo código é usado em outro lugar nesta classe? você deve evitar duplicação em quase todos os casos.

De qualquer forma, sempre pense em uma responsabilidade única. Uma classe deve ter uma responsabilidade, suas funções devem servir um único serviço constante e, se realizarem várias ações, essas ações deverão ter suas próprias funções, para que seja fácil diferenciá-las ou alterá-las posteriormente.

Tomer Blu
fonte
1

Torna-se mais difícil argumentar sobre esses métodos pequenos do que quando eram apenas blocos de código no grande, porque quando os extraio, perco muitas suposições subjacentes que vêm do contexto do chamador.

Eu não percebi o tamanho de um problema até eu adotar um ECS que encorajava funções maiores e mal definidas do sistema (com os sistemas sendo os únicos tendo funções) e dependências fluindo em direção a dados brutos , não a abstrações.

Isso, para minha surpresa, produziu uma base de código muito mais fácil de raciocinar e manter em comparação com as bases de código em que trabalhei no passado, nas quais, durante a depuração, era necessário rastrear todos os tipos de pequenas funções pequeninas, geralmente através de chamadas de funções abstratas através de interfaces puras que levam a quem sabe onde até você rastrear, apenas para gerar uma cascata de eventos que levam a lugares que você nunca pensou que o código deveria levar.

Ao contrário de John Carmack, meu maior problema com essas bases de código não era o desempenho, pois nunca tive essa demanda de latência ultra-rígida dos mecanismos de jogos AAA e a maioria dos nossos problemas de desempenho estava mais relacionada à taxa de transferência. É claro que você também pode começar a tornar cada vez mais difícil otimizar pontos de acesso quando estiver trabalhando em áreas cada vez mais estreitas de funções e classes cada vez mais adolescentes, sem que essa estrutura atrapalhe (exigindo a fusão de todas essas peças pequeninas de volta) para algo maior antes que você possa começar a resolvê-lo efetivamente).

No entanto, o maior problema para mim foi ser incapaz de raciocinar com confiança sobre a correção geral do sistema, apesar de todos os testes aprovados. Havia muito o que entender e compreender em meu cérebro, porque esse tipo de sistema não deixava você raciocinar sobre isso sem levar em conta todos esses pequenos detalhes e interações infinitas entre pequenas funções e objetos que estavam acontecendo em todos os lugares. Havia muitos "what ifs?", Muitas coisas que precisavam ser chamadas na hora certa, muitas perguntas sobre o que aconteceria se elas fossem chamadas na hora errada (que começam a se elevar ao ponto de paranóia quando você ter um evento acionando outro evento acionando outro levando você a todos os tipos de lugares imprevisíveis) etc.

Agora, eu gosto das minhas grandes funções de 80 linhas aqui e ali, desde que continuem desempenhando uma responsabilidade singular e clara e não possuam 8 níveis de blocos aninhados. Eles levam a uma sensação de que há menos coisas no sistema para testar e compreender, mesmo que as versões menores e detalhadas dessas funções maiores sejam apenas detalhes de implementação privada que não podem ser chamados por mais ninguém ... ainda, de alguma forma, tende a parecer que há menos interações em todo o sistema. Eu até gosto de alguma duplicação de código muito modesta, desde que não seja lógica complexa (digamos apenas 2-3 linhas de código), se isso significa menos funções. Eu gosto do raciocínio de Carmack sobre inlining tornando essa funcionalidade impossível de chamar em outro lugar no arquivo de origem. Lá'

A simplicidade nem sempre reduz a complexidade no nível geral, se a opção estiver entre uma função corpulenta e outras 12 super simples que se chamam com um gráfico complexo de dependências. No final do dia, muitas vezes você tem que raciocinar sobre o que acontece além de uma função, precisa raciocinar sobre o que essas funções acabam por fazer, e pode ser mais difícil ver esse quadro geral se for necessário deduzi-lo do menores peças de quebra-cabeça.

É claro que o código de tipo de biblioteca de uso geral muito bem testado pode ser isento desta regra, pois esse código de uso geral geralmente funciona e se mantém bem por si só. Além disso, ele tende a ser pequeno em comparação com o código, um pouco mais próximo do domínio do seu aplicativo (milhares de linhas de código, não milhões), e tão amplamente aplicável que começa a se tornar parte do vocabulário diário. Mas com algo mais específico para sua aplicação, em que os invariantes de todo o sistema que você precisa manter vão muito além de uma única função ou classe, eu acho que ajuda a ter funções mais elaboradas por qualquer motivo. Acho muito mais fácil trabalhar com peças maiores de quebra-cabeças na tentativa de descobrir o que está acontecendo com o quadro geral.


fonte
0

Não acho que seja um grande problema, mas concordo que é problemático. Normalmente, apenas coloco o auxiliar imediatamente após o beneficiário e adiciono o sufixo "Helper". Isso, mais o privateespecificador de acesso, deve deixar sua função clara. Se houver algum invariante que não se mantenha quando o auxiliar for chamado, adiciono um comentário ao auxiliar.

Esta solução tem a desvantagem infeliz de não capturar o escopo da função que ajuda. Idealmente, suas funções são pequenas, então espero que isso não resulte em muitos parâmetros. Normalmente, você resolveria isso definindo novas estruturas ou classes para agrupar os parâmetros, mas a quantidade de clichê necessária para isso pode facilmente ser mais longa que o próprio ajudante, e então você está de volta aonde começou, sem uma maneira óbvia de associar a estrutura com a função

Você já mencionou a outra solução - defina o auxiliar dentro da função principal. Pode ser um idioma um tanto incomum em alguns idiomas, mas não acho que seja confuso (a menos que seus colegas sejam confundidos com lambdas em geral). Isso só funciona se você pode definir funções ou objetos semelhantes a funções facilmente. Eu não tentaria isso no Java 7, por exemplo, uma vez que uma classe anônima exige a introdução de dois níveis de aninhamento, mesmo para a menor "função". Isso é o mais próximo possível de uma cláusula letou where; você pode consultar variáveis ​​locais antes que a definição e o auxiliar não possam ser usados ​​fora desse escopo.

Doval
fonte