Método de extracción vs supuestos subyacentes


27

Cuando divido grandes métodos (o procedimientos, o funciones), esta pregunta no es específica de OOP, pero dado que trabajo en lenguajes de OOP el 99% del tiempo, es la terminología con la que me siento más cómodo en muchos pequeños. , A menudo me encuentro disgustado con los resultados. Se hace más difícil razonar sobre estos pequeños métodos que cuando solo eran bloques de código en el grande, porque cuando los extraigo, pierdo muchas suposiciones subyacentes que provienen del contexto de la persona que llama.

Más tarde, cuando miro este código y veo métodos individuales, no sé inmediatamente de dónde se llaman, y pienso en ellos como métodos privados comunes que se pueden llamar desde cualquier parte del archivo. Por ejemplo, imagine un método de inicialización (constructor o de otro tipo) dividido en una serie de pequeños: en el contexto del método en sí, usted sabe claramente que el estado del objeto aún no es válido, pero en un método privado ordinario probablemente pase de suponer que el objeto ya está inicializado y está en un estado válido.

La única solución que he visto para esto es la wherecláusula en Haskell, que le permite definir pequeñas funciones que se usan solo en la función "padre". Básicamente, se ve así:

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

Pero otros lenguajes que uso no tienen nada como esto: lo más parecido es definir una lambda en un ámbito local, lo que probablemente sea aún más confuso.

Entonces, mi pregunta es: ¿te encuentras con esto e incluso ves que es un problema? Si lo hace, ¿cómo lo resuelve normalmente, particularmente en lenguajes OOP "convencionales", como Java / C # / C ++?

Edite sobre duplicados: como otros notaron, ya hay preguntas sobre métodos de división y pequeñas preguntas que son ingeniosas. Los leí, y no discuten el tema de los supuestos subyacentes que pueden derivarse del contexto de la persona que llama (en el ejemplo anterior, se inicializa el objeto). Ese es el punto de mi pregunta, y es por eso que mi pregunta es diferente.

Actualización: Si siguió esta pregunta y discusión debajo, podría disfrutar este artículo de John Carmack sobre el asunto , en particular:

Además de conocer el código real que se está ejecutando, las funciones en línea también tienen el beneficio de no permitir llamar a la función desde otros lugares. Eso suena ridículo, pero tiene sentido. A medida que un código base crece con los años de uso, habrá muchas oportunidades para tomar un atajo y simplemente llamar a una función que solo hace el trabajo que cree que debe hacerse. Puede haber una función FullUpdate () que llame a PartialUpdateA () y PartialUpdateB (), pero en algún caso particular puede darse cuenta (o pensar) que solo necesita hacer PartialUpdateB (), y está siendo eficiente al evitar el otro trabajo. Muchos y muchos errores se derivan de esto. La mayoría de los errores son el resultado de que el estado de ejecución no es exactamente lo que crees que es.




@gnat la pregunta a la que se vinculó analiza si extraer o no las funciones, aunque no lo cuestiono. En cambio, cuestiono el método más óptimo para hacerlo.
Max Yankov

2
@gnat hay otras preguntas relacionadas vinculadas desde allí, pero ninguna de estas discute el hecho de que este código puede basarse en suposiciones específicas que son válidas solo en el contexto de la persona que llama.
Max Yankov

1
@Doval en mi experiencia, realmente lo hace. Cuando hay métodos auxiliares problemáticos dando vueltas como usted describe, la extracción de una nueva clase cohesiva se encarga de esto
mosquito

Respuestas:


29

Por ejemplo, imagine un método de inicialización dividido en una serie de pequeños: en el contexto del método en sí, usted sabe claramente que el estado del objeto todavía no es válido, pero en un método privado ordinario probablemente pase de suponer que el objeto ya está inicializado y está En un estado válido. La única solución que he visto para esto es ...

Tu preocupación está bien fundada. Hay otra solucion.

Da un paso atrás. ¿Cuál es fundamentalmente el propósito de un método? Los métodos solo hacen una de dos cosas:

  • Producir un valor
  • Causar un efecto

O, desafortunadamente, ambos. Intento evitar los métodos que hacen ambas cosas, pero muchas lo hacen. Digamos que el efecto producido o el valor producido es el "resultado" del método.

Usted nota que los métodos se llaman en un "contexto". ¿Cuál es ese contexto?

  • Los valores de los argumentos.
  • El estado del programa fuera del método.

Esencialmente, lo que está señalando es: la exactitud del resultado del método depende del contexto en el que se llama .

Llamamos a las condiciones requeridas antes de un cuerpo de método comienza para el método para producir un resultado correcto sus condiciones previas , y llamamos a las condiciones que se producen después de que el cuerpo del método devuelve sus condiciones posteriores .

Entonces, esencialmente, lo que está señalando es: cuando extraigo un bloque de código en su propio método, estoy perdiendo información contextual sobre las condiciones previas y posteriores .

La solución a este problema es hacer que las condiciones previas y posteriores sean explícitas en el programa . En C #, por ejemplo, puede usar Debug.Asserto codificar contratos para expresar condiciones previas y posteriores.

Por ejemplo: solía trabajar en un compilador que se movía a través de varias "etapas" de compilación. Primero, el código se lexificaría, luego se analizaría, luego los tipos se resolverían, luego las jerarquías de herencia se verificarían por ciclos, y así sucesivamente. Cada parte del código era muy sensible a su contexto; sería desastroso, por ejemplo, preguntar "¿este tipo es convertible a ese tipo?" ¡si el gráfico de tipos base todavía no se sabía que era acíclico! Por lo tanto, cada fragmento de código documenta claramente sus condiciones previas. En assertel método que verificó la convertibilidad de tipo, ya habíamos pasado la verificación de "tipos básicos de acilo", y luego quedó claro para el lector dónde podía llamarse el método y dónde no.

Por supuesto, hay muchas formas en que un buen diseño del método mitiga el problema que ha identificado:

  • crear métodos que sean útiles por sus efectos o su valor, pero no ambos
  • hacer métodos que sean tan "puros" como sea posible; un método "puro" produce un valor que depende solo de sus argumentos y no produce ningún efecto. Estos son los métodos más fáciles de razonar porque el "contexto" que necesitan está muy localizado.
  • minimizar la cantidad de mutación que ocurre en el estado del programa; las mutaciones son puntos donde el código se vuelve más difícil de razonar

+1 por ser la respuesta que explica el problema en términos de precondiciones / postcondiciones.
QuestionC

55
Agregaría que a menudo es posible (¡y una buena idea!) Delegar la verificación de las condiciones previas y posteriores al sistema de tipos. Si tiene una función que toma una stringy la guarda en la base de datos, corre el riesgo de inyección SQL si olvida limpiarla. Si, por otro lado, su función toma un SanitisedString, y la única forma de obtener un SantisiedStringes llamando Sanitise, entonces ha descartado los errores de inyección SQL por construcción. Cada vez más me encuentro buscando formas de hacer que el compilador rechace el código incorrecto.
Benjamin Hodgson

+1 Una cosa que es importante tener en cuenta es que hay un costo para dividir un método grande en trozos más pequeños: generalmente no es útil a menos que las condiciones previas y posteriores sean más relajadas de lo que hubieran sido originalmente, y puede terminar teniendo que pague el costo volviendo a hacer los cheques que de otro modo ya habría hecho. No es un proceso de refactorización completamente "gratuito".
Mehrdad

"¿Cuál es ese contexto?" solo para aclarar, me refería principalmente al estado privado del objeto al que se llama este método. Supongo que está incluido en la segunda categoría.
Max Yankov

Esta es una respuesta excelente y estimulante, gracias. (No quiere decir que otras respuestas sean de ninguna manera malas, por supuesto). Todavía no marcaré la pregunta como contestada, porque realmente me gusta la discusión aquí (y tiende a cesar cuando la respuesta se marca como contestada) y necesito tiempo para procesarla y pensar en ello.
Max Yankov

13

A menudo veo esto, y estoy de acuerdo en que es un problema. Por lo general, lo resuelvo creando un objeto de método : una nueva clase especializada cuyos miembros son las variables locales del método original demasiado grande.

La nueva clase tiende a tener un nombre como 'Exportador' o 'Tabulación', y se pasa cualquier información necesaria para hacer esa tarea en particular desde un contexto más amplio. Entonces es libre de definir fragmentos de código auxiliar aún más pequeños que no están en peligro de ser utilizados para nada más que tabular o exportar.


Realmente me gusta esta idea cuanto más lo pienso. Puede ser una clase privada dentro de la clase pública o interna. No abarrota su espacio de nombres con clases que solo le interesan muy localmente, y es una forma de marcar que estos son "ayudantes de construcción" o "ayudantes de análisis" o lo que sea.
Mike apoya a Mónica el

Recientemente estuve en una situación que sería ideal para esto desde la perspectiva de la arquitectura. Escribí un renderizador de software con una clase de renderizador y un método de render público, que tenía MUCHO contexto que solía llamar a otros métodos. Contemplé la creación de una clase RenderContext separada para esto, sin embargo, me pareció un enorme desperdicio asignar y desasignar este proyecto en cada fotograma. github.com/golergka/tinyrenderer/blob/master/src/renderer.h
Max Yankov

6

Muchos idiomas le permiten anidar funciones como Haskell. Java / C # / C ++ son en realidad valores atípicos relativos a ese respecto. Por desgracia, son tan populares que la gente llega a pensar: "Se tiene que ser una mala idea, si no mi 'corriente principal' idioma favorito lo permitiría."

Java / C # / C ++ básicamente piensa que una clase debería ser la única agrupación de métodos que necesita. Si tiene tantos métodos que no puede determinar sus contextos, hay dos enfoques generales que debe tomar: ordenarlos por contexto o dividirlos por contexto.

Ordenar por contexto es una recomendación hecha en Clean Code , donde el autor describe un patrón de "párrafos TO". Básicamente, esto es poner sus funciones de ayuda inmediatamente después de la función que las llama, para que pueda leerlas como párrafos en un artículo de periódico, obteniendo más detalles cuanto más lea. Creo que en sus videos incluso los sangra.

El otro enfoque es dividir tus clases. Esto no puede llevarse muy lejos, debido a la molesta necesidad de crear instancias de objetos antes de que pueda invocar cualquier método sobre ellos, y los problemas inherentes con la decisión de cuál de varias clases pequeñas debería poseer cada pieza de datos. Sin embargo, si ya ha identificado varios métodos que realmente solo encajan en un contexto, es probable que sean un buen candidato para considerar su propia clase. Por ejemplo, la inicialización compleja se puede hacer en un patrón de creación como el generador.


Funciones de anidamiento ... ¿no es eso lo que logran las funciones lambda en C # (y Java 8)?
Arturo Torres Sánchez

Estaba pensando más como un cierre definido con un nombre, como estos ejemplos de Python . Las lambdas no son la forma más clara de hacer algo así. Son más para expresiones cortas como un predicado de filtro.
Karl Bielefeldt el

Esos ejemplos de Python son ciertamente posibles en C #. Por ejemplo, el factorial . Pueden ser más detallados, pero son 100% posibles.
Arturo Torres Sánchez

2
Nadie dijo que no era posible. El OP incluso mencionó el uso de lambdas en su pregunta. Es solo que si extrae un método en aras de la legibilidad, sería bueno que fuera más legible.
Karl Bielefeldt el

Su primer párrafo parece implicar que no es posible, especialmente con su cita: "Tiene que ser una mala idea, de lo contrario mi lenguaje 'convencional' lo permitiría".
Arturo Torres Sánchez

4

Creo que la respuesta en la mayoría de los casos es el contexto. Como desarrollador que escribe código, debe asumir que su código se va a cambiar en el futuro. Una clase puede integrarse con otra clase, puede reemplazar su algoritmo interno o puede dividirse en varias clases para crear abstracción. Esas son cosas que los desarrolladores principiantes generalmente no toman en cuenta, lo que provoca la necesidad de soluciones alternativas desordenadas o revisiones completas más tarde.

La extracción de métodos es buena, pero hasta cierto punto. Siempre trato de hacerme estas preguntas al inspeccionar o antes de escribir el código:

  • ¿Este código solo lo usa esta clase / función? ¿Se mantendrá igual en el futuro?
  • Si necesito cambiar parte de la implementación concreta, ¿puedo hacerlo fácilmente?
  • ¿Pueden otros desarrolladores de mi equipo entender qué se hace en esta función?
  • ¿Se usa el mismo código en otro lugar de esta clase? Debe evitar la duplicación en casi todos los casos.

En cualquier caso, siempre piense en responsabilidad individual. Una clase debe tener una responsabilidad, sus funciones deben servir a un único servicio constante, y si realizan una serie de acciones, esas acciones deben tener sus propias funciones, por lo que es fácil diferenciarlas o cambiarlas más adelante.


1

Se hace más difícil razonar sobre estos pequeños métodos que cuando solo eran bloques de código en el grande, porque cuando los extraigo, pierdo muchas suposiciones subyacentes que provienen del contexto de la persona que llama.

No me di cuenta de lo grande que era este problema hasta que adopté un ECS que fomentaba las funciones de sistema más grandes y en bucle (siendo los sistemas los únicos que tenían funciones) y las dependencias que fluían hacia datos sin procesar , no abstracciones.

Para mi sorpresa, produjo una base de código mucho más fácil de razonar y mantener en comparación con las bases de código en las que trabajé en el pasado donde, durante la depuración, tenía que rastrear todo tipo de pequeñas funciones, a menudo a través de llamadas de funciones abstractas a través de interfaces puras que conducen a quién sabe dónde hasta que se rastrea, solo para generar una cascada de eventos que conducen a lugares que nunca pensaste que el código debería llevar.

A diferencia de John Carmack, mi mayor problema con esas bases de código no era el rendimiento, ya que nunca tuve esa demanda de latencia ultra ajustada de los motores de juegos AAA y la mayoría de nuestros problemas de rendimiento se relacionaron más con el rendimiento. Por supuesto, también puede comenzar a hacer que sea cada vez más difícil optimizar los puntos de acceso cuando trabaje en confines cada vez más estrechos de funciones y clases cada vez más pequeñas sin que esa estructura se interponga en el camino (lo que requiere que vuelva a fusionar todas estas piezas pequeñas) a algo más grande antes de que puedas comenzar a abordarlo de manera efectiva).

Sin embargo, el mayor problema para mí fue no poder razonar con confianza sobre la corrección general del sistema a pesar de que se pasaron todas las pruebas. Había demasiado para entender y comprender porque ese tipo de sistema no te permitía razonar al respecto sin tener en cuenta todos estos pequeños detalles e interacciones interminables entre pequeñas funciones y objetos que estaban sucediendo en todas partes. Había demasiados "¿qué pasaría si?", Demasiadas cosas que debían llamarse en el momento adecuado, demasiadas preguntas sobre lo que sucedería si se les llamara en el momento equivocado (que comienzan a llegar al punto de la paranoia cuando tener un evento que desencadena otro evento que desencadena otro que lo lleva a todo tipo de lugares impredecibles), etc.

Ahora me gustan mis grandes funciones de 80 líneas aquí y allá, siempre y cuando sigan desempeñando una responsabilidad singular y clara y no tengan como 8 niveles de bloques anidados. Conducen a la sensación de que hay menos cosas en el sistema para probar y comprender, incluso si las versiones más pequeñas y cortadas de estas funciones más grandes eran solo detalles de implementación privados que nadie más puede llamar ... aún, de alguna manera, tiende a sentir que hay menos interacciones en todo el sistema. Incluso me gusta una duplicación de código muy modesta, siempre que no sea una lógica compleja (digamos solo 2-3 líneas de código), si significa menos funciones. Me gusta el razonamiento de Carmack sobre la inlínea haciendo que esa funcionalidad sea imposible de llamar a otra parte del archivo fuente. Allí'

La simplicidad no siempre reduce la complejidad en el nivel global si la opción es entre una función carnosa versus 12 funciones súper simples que se llaman entre sí con un gráfico complejo de dependencias. Al final del día, a menudo tiene que razonar sobre lo que sucede más allá de una función, tiene que razonar sobre lo que estas funciones suman en última instancia, y puede ser más difícil ver ese panorama general si tiene que deducirlo del piezas de rompecabezas más pequeñas.

Por supuesto, el código de tipo de biblioteca de uso muy general que está bien probado puede estar exento de esta regla, ya que dicho código de uso general a menudo funciona y se mantiene bien por sí solo. También tiende a ser pequeño comparado con el código un poco más cercano al dominio de su aplicación (miles de líneas de código, no millones), y tan ampliamente aplicable que comienza a formar parte del vocabulario diario. Pero con algo más específico para su aplicación donde los invariantes de todo el sistema que tiene que mantener van mucho más allá de una sola función o clase, tiendo a encontrar que ayuda a tener funciones más complejas por cualquier razón. Me resulta mucho más fácil trabajar con piezas de rompecabezas más grandes al tratar de descubrir qué está pasando con el panorama general.


0

No creo que sea un gran problema, pero estoy de acuerdo en que es problemático. Por lo general, solo coloco el ayudante inmediatamente después de su beneficiario y agrego un sufijo "Ayudante". Eso más el privateespecificador de acceso debería dejar en claro su papel. Si hay alguna invariante que no se mantiene cuando se llama al ayudante, agrego un comentario en el ayudante.

Esta solución tiene el inconveniente desafortunado de no capturar el alcance de la función que ayuda. Idealmente, sus funciones son pequeñas, así que espero que esto no dé como resultado demasiados parámetros. Normalmente, resolvería esto definiendo nuevas estructuras o clases para agrupar los parámetros, pero la cantidad de repetitivo requerida para eso puede ser fácilmente más larga que la del ayudante, y luego está de regreso donde comenzó sin ninguna forma obvia de asociarse La estructura con la función.

Ya mencionó la otra solución: definir el ayudante dentro de la función principal. Puede ser un idioma poco común en algunos idiomas, pero no creo que sea confuso (a menos que sus compañeros estén confundidos por lambdas en general). Sin embargo, esto solo funciona si puede definir funciones u objetos similares a funciones. No probaría esto en Java 7, por ejemplo, ya que una clase anónima requiere la introducción de 2 niveles de anidación incluso para la "función" más pequeña. Esto es lo más cercano a una leto wherecláusula que pueda obtener; puede hacer referencia a variables locales antes de la definición y el asistente no se puede usar fuera de ese alcance.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.