¿El contrato semántico de una interfaz (OOP) es más informativo que una firma de función (FP)?

16

Algunos dicen que si lleva los principios SOLID a sus extremos, terminará en la programación funcional . Estoy de acuerdo con este artículo, pero creo que se pierde algo de semántica en la transición de la interfaz / objeto a la función / cierre, y quiero saber cómo la programación funcional puede mitigar la pérdida.

Del artículo:

Además, si aplica rigurosamente el Principio de segregación de interfaz (ISP), comprenderá que debe favorecer las interfaces de rol sobre las interfaces de encabezado.

Si sigue impulsando su diseño hacia interfaces cada vez más pequeñas, eventualmente llegará a la interfaz de roles definitiva: una interfaz con un solo método. Esto me pasa mucho. Aquí hay un ejemplo:

public interface IMessageQuery
{
    string Read(int id);
}

Si tomo una dependencia de una IMessageQueryparte del contrato implícito, esa llamada Read(id)buscará y devolverá un mensaje con la ID dada.

Compare esto con tomar una dependencia de su firma funcional equivalente int -> string. Sin señales adicionales, esta función podría ser simple ToString(). Si lo implementaste IMessageQuery.Read(int id)con un ToString()¡puedo acusarlo de ser deliberadamente subversivo!

Entonces, ¿qué pueden hacer los programadores funcionales para preservar la semántica de una interfaz bien nombrada? ¿Es convencional, por ejemplo, crear un tipo de registro con un solo miembro?

type MessageQuery = {
    Read: int -> string
}
AlexFoxGill
fuente
55
Una interfaz OOP se parece más a la clase de tipo FP , no a una sola función.
9000
2
Puede tener demasiado de algo bueno, aplicar ISP 'rigurosamente' para terminar con 1 método por interfaz es ir demasiado lejos.
gbjbaanb
3
@gbjbaanb en realidad la mayoría de mis interfaces tienen solo un método con muchas implementaciones. Cuanto más aplique los principios SOLID, más podrá ver los beneficios. Pero eso está fuera de tema para esta pregunta
AlexFoxGill
1
@jk .: Bueno, en Haskell, son clases de tipo, en OCaml usas un módulo o un functor, en Clojure usas un protocolo. En cualquier caso, no suele limitar la analogía de su interfaz a una sola función.
9000
2
Without any additional clues... tal vez es por eso que la documentación es parte del contrato ?
SJuan76

Respuestas:

8

Como dice Telastyn, comparando las definiciones estáticas de funciones:

public string Read(int id) { /*...*/ }

a

let read (id:int) = //...

Realmente no has perdido nada al pasar de OOP a FP.

Sin embargo, esto es solo una parte de la historia, porque las funciones y las interfaces no solo se mencionan en sus definiciones estáticas. También se pasan . Entonces, digamos que nuestro MessageQueryfue leído por otro código, a MessageProcessor. Entonces tenemos:

public void ProcessMessage(int messageId, IMessageQuery messageReader) { /*...*/ }

Ahora no podemos ver directamente el nombre del método IMessageQuery.Reado su parámetro int id, pero podemos llegar muy fácilmente a través de nuestro IDE. En términos más generales, el hecho de que estamos pasando una IMessageQueryinterfaz en lugar de cualquier interfaz con un método de una función de int a string significa que mantenemos los idmetadatos de nombre de parámetro asociados con esta función.

Por otro lado, para nuestra versión funcional tenemos:

let read (id:int) (messageReader : int -> string) = // ...

Entonces, ¿qué hemos guardado y perdido? Bueno, todavía tenemos el nombre del parámetro messageReader, lo que probablemente hace IMessageQueryinnecesario el nombre del tipo (el equivalente a ). Pero ahora hemos perdido el nombre del parámetro iden nuestra función.


Hay dos formas principales de evitar esto:

  • En primer lugar, al leer esa firma, ya puedes adivinar lo que sucederá. Al mantener las funciones cortas, simples y coherentes y al utilizar una buena nomenclatura, hace que sea mucho más fácil intuir o encontrar esta información. Una vez que comenzamos a leer la función en sí misma, sería aún más simple.

  • En segundo lugar, se considera un diseño idiomático en muchos lenguajes funcionales para crear tipos pequeños para envolver primitivas. En este caso, sucede lo contrario: en lugar de reemplazar un nombre de tipo con un nombre de parámetro ( IMessageQueryto messageReader) podemos reemplazar un nombre de parámetro con un nombre de tipo. Por ejemplo, intpodría envolverse en un tipo llamado Id:

    type Id = Id of int
    

    Ahora nuestra readfirma se convierte en:

    let read (id:int) (messageReader : Id -> string) = // ...
    

    Lo cual es tan informativo como lo que teníamos antes.

    Como nota al margen, esto también nos proporciona parte de la protección del compilador que teníamos en OOP. Mientras que la versión OOP garantizaba que tomáramos específicamente una función anterior en IMessageQuerylugar de una simple int -> string, aquí tenemos una protección similar (pero diferente) que estamos tomando en Id -> stringlugar de cualquier vieja int -> string.


Sería reacio a decir con 100% de confianza que estas técnicas siempre serán tan buenas e informativas como tener la información completa disponible en una interfaz, pero creo que de los ejemplos anteriores, puede decir que la mayoría de las veces, nosotros probablemente pueda hacer un trabajo igual de bueno.

Ben Aaronson
fuente
1
Esta es mi respuesta favorita: que FP fomenta el uso de tipos semánticos
AlexFoxGill
10

Cuando hago FP, tiendo a usar tipos semánticos más específicos.

Por ejemplo, su método para mí se convertiría en algo como:

read: MessageId -> Message

Esto comunica mucho más que el estilo de ThingDoer.doThing()estilo OO (/ java)

Daenyth
fuente
2
+1. Además, puede codificar muchas más propiedades en el sistema de tipos en lenguajes FP escritos como Haskell. Si ese fuera un tipo de haskell, sabría que no está haciendo ningún IO en absoluto (y, por lo tanto, es probable que haga referencia a alguna asignación en memoria de identificadores a mensajes en algún lugar) En los idiomas de OOP, no tengo esa información: esta función podría activar una base de datos como parte de su invocación.
Jack
1
No estoy exactamente claro por qué esto se comunicaría más que el estilo Java. ¿Qué read: MessageId -> Messagete dice que string MessageReader.GetMessage(int messageId)no?
Ben Aaronson
@BenAaronson la relación señal / ruido es mejor. Si es un método de instancia OO, no tengo idea de lo que está haciendo bajo el capó, podría tener algún tipo de dependencia que no se exprese. Como Jack menciona, cuando tienes tipos más fuertes, tienes más garantías.
Daenyth
9

Entonces, ¿qué pueden hacer los programadores funcionales para preservar la semántica de una interfaz bien nombrada?

Use funciones bien nombradas.

IMessageQuery::Read: int -> stringsimplemente se convierte ReadMessageQuery: int -> stringo algo similar.

La clave a tener en cuenta es que los nombres son solo contratos en el sentido más amplio de la palabra. Solo funcionan si usted y otro programador deducen las mismas implicaciones del nombre y las obedecen. Debido a esto, realmente puede usar cualquier nombre que comunique ese comportamiento implícito. OO y la programación funcional tienen sus nombres en lugares ligeramente diferentes y en formas ligeramente diferentes, pero su función es la misma.

¿El contrato semántico de una interfaz (OOP) es más informativo que una firma de función (FP)?

No en este ejemplo. Como expliqué anteriormente, una sola clase / interfaz con una sola función no es significativamente más informativa que una función independiente con un nombre similar.

Una vez que obtiene más de una función / campo / propiedad en una clase, puede inferir más información sobre ellos porque puede ver su relación. Es discutible si eso es más informativo que las funciones independientes que toman los mismos parámetros / parámetros similares o funciones independientes organizadas por espacio de nombres o módulo.

Personalmente, no creo que OO sea significativamente más informativo, incluso en ejemplos más complejos.

Telastyn
fuente
2
¿Qué pasa con los nombres de los parámetros?
Ben Aaronson
@BenAaronson: ¿qué hay de ellos? Tanto los lenguajes funcionales como los de OO le permiten especificar los nombres de los parámetros, por lo que es un empuje. Solo estoy usando la abreviatura de firma de tipo aquí para ser coherente con la pregunta. En un idioma real se vería algo asíReadMessageQuery id = <code to go fetch based on id>
Telastyn
1
Bueno, si una función toma una interfaz como parámetro, luego llama a un método en esa interfaz, los nombres de los parámetros para el método de la interfaz están fácilmente disponibles. Si una función toma otra función como parámetro, no es fácil asignar nombres de parámetros para esa función aprobada
Ben Aaronson
1
Sí, @BenAaronson esta es una de las piezas de información que se pierde en la firma de la función. Por ejemplo en let consumer (messageQuery : int -> string) = messageQuery 5, el idparámetro es solo un int. Supongo que un argumento es que deberías pasar un Id, no un int. De hecho, sería una respuesta decente en sí misma.
AlexFoxGill
@AlexFoxGill ¡Realmente estaba en el proceso de escribir algo en esa misma línea!
Ben Aaronson
0

No estoy de acuerdo con que una sola función no pueda tener un 'contrato semántico'. Considere estas leyes para foldr:

foldr f z nil = z
foldr f z (singleton x) = f x z
foldr f z (xn <> ys) = foldr f (foldr f z ys) xn

¿En qué sentido no es semántico o no es un contrato? No es necesario que defina un tipo para un "plegador", especialmente porque foldrestá determinado únicamente por esas leyes. Sabes exactamente lo que va a hacer.

Si desea tomar una función de algún tipo, puede hacer lo mismo:

-- The first argument `f` must satisfy for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
sort :: forall 'a. (a -> a -> bool.t) -> list.t a -> list.t a;

Solo necesita nombrar y capturar ese tipo si necesita el mismo contrato varias veces:

-- Type of functions `f` satisfying, for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
type comparison.t 'a = a -> a -> bool.t;

El verificador de tipos no impondrá la semántica que asigne a un tipo, por lo que crear un nuevo tipo para cada contrato es simplemente repetitivo.

Jonathan Cast
fuente
1
No creo que su primer punto aborde la pregunta: es una definición de función, no una firma. Por supuesto, al observar la implementación de una clase o una función, puede saber lo que hace: la pregunta es si aún puede preservar la semántica en un nivel de abstracción (una interfaz o firma de función)
AlexFoxGill
@AlexFoxGill - es no una definición de función, aunque se determina de forma única foldr. Sugerencia: la definición de foldrtiene dos ecuaciones (¿por qué?), Mientras que la especificación dada anteriormente tiene tres.
Jonathan Cast
Lo que quiero decir es que foldr es una función, no una firma de función. Las leyes o la definición no son parte de la firma
AlexFoxGill
-1

Casi todos los lenguajes funcionales de tipo estático tienen una forma de alias de tipos básicos de una manera que requiere que declares explícitamente tu intención semántica. Algunas de las otras respuestas han dado ejemplos. En la práctica, los programadores funcionales experimentados necesitan muy buenas razones para usar esos tipos de envoltorios, ya que perjudican la componibilidad y la reutilización.

Por ejemplo, supongamos que un cliente quería una implementación de una consulta de mensaje respaldada por una lista. En Haskell, la implementación podría ser tan simple como esta:

messages = ["Message 0", "Message 1", "Message 2"]
messageQuery = (messages !!)

Usar newtype Message = Message Stringesto sería mucho menos sencillo, dejando esta implementación parecida a:

messages = map Message ["Message 0", "Message 1", "Message 2"]
messageQuery (Id index) = messages !! index

Puede que no parezca un gran problema, pero tienes que hacer esa conversión de tipo de un lado a otro en todas partes , o configurar una capa límite en tu código donde todo lo que está arriba se Int -> Stringconvierte Id -> Messagepara pasar a la capa de abajo. Digamos que quería agregar internacionalización, o formatearlo en mayúsculas, o agregar contexto de registro, o lo que sea. Esas operaciones son totalmente simples de componer con un Int -> String, y molestas con un Id -> Message. No es que nunca haya casos en los que las restricciones de tipo aumentadas sean deseables, pero la molestia debería valer la pena.

Puede usar un sinónimo de tipo en lugar de un contenedor (en Haskell en typelugar de newtype), que es mucho más común y no requiere las conversiones en todo el lugar, pero tampoco proporciona las garantías de tipo estático como su versión OOP, solo un poco de enapsulación. Los envoltorios de tipos se usan principalmente cuando no se espera que el cliente manipule el valor, solo almacénelo y páselo de vuelta. Por ejemplo, un identificador de archivo.

Nada puede evitar que un cliente sea "subversivo". Simplemente está creando aros para saltar, para cada cliente. Un simulacro simple para una prueba de unidad común a menudo requiere un comportamiento extraño que no tiene sentido en la producción. Sus interfaces deben estar escritas para que no les importe, si es posible.

Karl Bielefeldt
fuente
1
Esto se parece más a un comentario sobre las respuestas de Ben / Daenyth: ¿puede usar tipos semánticos pero no debido a las molestias? No te rechacé pero no es una respuesta a esta pregunta.
AlexFoxGill
-1 No dañaría la componibilidad o la reutilización en absoluto. Si Idy Messageson envoltorios simples para Inty String, es trivial convertir entre ellos.
Michael Shaw
Sí, es trivial, pero sigue siendo molesto hacerlo por todos lados. He agregado un ejemplo y un poco que describe la diferencia entre el uso de sinónimos de tipo y envoltorios.
Karl Bielefeldt
Esto parece una cosa de tamaño. En proyectos pequeños, este tipo de envoltorios probablemente no sean tan útiles de todos modos. En proyectos grandes, es natural que los límites sean una pequeña proporción del código general, por lo que hacer este tipo de conversiones en el límite y luego pasar tipos envueltos en cualquier otro lugar no es demasiado arduo. Además, como dijo Alex, no veo cómo esta es una respuesta a la pregunta.
Ben Aaronson
Le expliqué cómo hacerlo, y luego por qué los programadores funcionales no lo harían. Esa es una respuesta, incluso si no es la que la gente quería. No tiene nada que ver con el tamaño. Esta idea de que de alguna manera es bueno hacer intencionalmente que sea más difícil reutilizar su código es decididamente un concepto OOP. ¿Crear un tipo de producto que agregue algo de valor? Todo el tiempo. ¿Crear un tipo exactamente como un tipo ampliamente utilizado, excepto que solo puede usarlo en una biblioteca? Prácticamente inaudito, excepto quizás en el código FP que interactúa fuertemente con el código OOP, o escrito por alguien familiarizado con los modismos de OOP.
Karl Bielefeldt