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 where
clá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.
Respuestas:
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:
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?
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.Assert
o 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
assert
el 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:
fuente
string
y 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 unSanitisedString
, y la única forma de obtener unSantisiedString
es llamandoSanitise
, 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.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.
fuente
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.
fuente
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:
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.
fuente
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.
fuente
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
private
especificador 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
let
owhere
clá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.fuente