Usar Func en lugar de interfaces para IoC

15

Contexto: estoy usando C #

Diseñé una clase y, para aislarla y facilitar las pruebas unitarias, paso todas sus dependencias; no hace objeto de instanciación internamente. Sin embargo, en lugar de hacer referencia a interfaces para obtener los datos que necesita, hago referencia a Funcs de propósito general que devuelven los datos / comportamiento que requiere. Cuando inyecto sus dependencias, puedo hacerlo con expresiones lambda.

Para mí, esto parece un mejor enfoque porque no tengo que hacer ninguna burla tediosa durante la prueba de la unidad. Además, si la implementación circundante tiene cambios fundamentales, solo necesitaré cambiar la clase de fábrica; No se necesitarán cambios en la clase que contiene la lógica.

Sin embargo, nunca antes había visto a IoC hacerlo de esta manera, lo que me hace pensar que hay algunos escollos potenciales que me pueden faltar. Lo único que se me ocurre es una incompatibilidad menor con la versión anterior de C # que no define Func, y este no es un problema en mi caso.

¿Hay algún problema con el uso de delegados genéricos / funciones de orden superior como Func para IoC, en lugar de interfaces más específicas?

TheCatWhisperer
fuente
2
Lo que está describiendo son funciones de orden superior, una faceta importante de la programación funcional.
Robert Harvey
2
Usaría un delegado en lugar de un, Funcya que puede nombrar los parámetros, donde puede indicar su intención.
Stefan Hanke
1
relacionado: stackoverflow: ioc-factory-pros-and-contras-for-interface-versus-delegates . La pregunta de @TheCatWhisperer es más general, mientras que la pregunta de stackoverflow se reduce al caso especial "fábrica"
k3b

Respuestas:

11

Si una interfaz contiene solo una función, no más, y no hay una razón convincente para introducir dos nombres (el nombre de la interfaz y el nombre de la función dentro de la interfaz), usar un Funclugar puede evitar un código innecesario y en la mayoría de los casos es preferible: al igual que cuando comienza a diseñar un DTO y reconoce que solo necesita un atributo de miembro.

Supongo que muchas personas están más acostumbradas a usar interfaces, porque en el momento en que la inyección de dependencia y la IoC se estaban volviendo populares, no había un equivalente real de la Funcclase en Java o C ++ (ni siquiera estoy seguro de si Funcestaba disponible en ese momento en C # ) Por lo tanto, muchos tutoriales, ejemplos o libros de texto aún prefieren la interfaceforma, incluso si usarlos Funcfuera más elegante.

Puede consultar mi respuesta anterior sobre el principio de segregación de interfaz y el enfoque de diseño de flujo de Ralf Westphal. Este paradigma implementa DI con Funcparámetros exclusivamente , exactamente por la misma razón que usted ya mencionó (y algunos más). Como puede ver, su idea no es realmente nueva, sino todo lo contrario.

Y sí, utilicé este enfoque solo, para el código de producción, para los programas que necesitaban procesar datos en forma de una tubería con varios pasos intermedios, incluidas las pruebas unitarias para cada uno de los pasos. Entonces, a partir de eso, puedo brindarle experiencia de primera mano que puede funcionar muy bien.

Doc Brown
fuente
Este programa ni siquiera fue diseñado con el principio de segregación de interfaz en mente, básicamente se usan como clases abstractas. Esta es una razón más por la que seguí este enfoque, las interfaces no están bien pensadas y en su mayoría son inútiles, ya que solo una clase las implementa de todos modos. El principio de segregación de interfaz no tiene que ser llevado al extremo, especialmente ahora que tenemos Func, pero en mi opinión, todavía es muy importante.
TheCatWhisperer
1
Este programa ni siquiera fue diseñado teniendo en cuenta el principio de segregación de la interfaz ; bueno, según su descripción, probablemente no era consciente de que el término podría aplicarse a su tipo de diseño.
Doc Brown
Doc, me refería al código hecho por mis predecesores, que estoy refactorizando, y del cual mi nueva clase depende en cierta medida. Parte de la razón por la que opté por este enfoque es porque planeo hacer cambios importantes en otras partes del programa en el futuro, incluidas las dependencias de esta clase
TheCatWhisperer
1
"... En el momento en que la inyección de dependencia y la IoC se estaban volviendo populares, no había un equivalente real a la clase Func" ¡Eso es exactamente lo que casi respondí pero realmente no me sentí lo suficientemente seguro! Gracias por verificar mi sospecha al respecto.
Graham
9

Una de las principales ventajas que encuentro con IoC es que me permitirá nombrar todas mis dependencias nombrando su interfaz, y el contenedor sabrá cuál proporcionar al constructor haciendo coincidir los nombres de los tipos. Esto es conveniente y permite nombres de dependencias mucho más descriptivos que Func<string, string>.

También a menudo encuentro que, incluso con una dependencia simple y simple, a veces necesita tener más de una función: una interfaz le permite agrupar estas funciones de una manera que es autodocumentada, en lugar de tener múltiples parámetros que todos se leen Func<string, string>, Func<string, int>.

Definitivamente, hay momentos en los que es útil simplemente tener un delegado que se pasa como una dependencia. Es una decisión decisiva cuando se utiliza un delegado frente a tener una interfaz con muy pocos miembros. A menos que esté realmente claro cuál es el propósito del argumento, generalmente me equivocaré al producir código autodocumentado; es decir. escribiendo una interfaz.

Erik
fuente
Tuve que depurar el código de alguien, cuando el implementador original ya no estaba allí, y puedo decirle por primera experiencia, que descubrir qué lambda se ejecuta en la pila es mucho trabajo y un proceso realmente frustrante. Si el implementador original hubiera usado interfaces, habría sido muy fácil encontrar el error que estaba buscando.
cwap
cwap, siento tu dolor. Sin embargo, en mi implementación particular, las lambdas son todas líneas que apuntan a una sola función. También se generan todos en un solo lugar.
TheCatWhisperer
En mi experiencia, esto es solo una cuestión de herramientas. ReSharpers Inspect-> Incoming Calls hace un buen trabajo al seguir el camino por los funcs / lambdas.
Christian
3

¿Hay algún problema con el uso de delegados genéricos / funciones de orden superior como Func para IoC, en lugar de interfaces más específicas?

Realmente no. Un Func es su propio tipo de interfaz (en el significado en inglés, no en el significado de C #). "Este parámetro es algo que proporciona X cuando se le pregunta". El Func incluso tiene el beneficio de proporcionar de manera perezosa la información solo según sea necesario. Hago esto un poco y lo recomiendo con moderación.

En cuanto a las desventajas:

  • Los contenedores de IoC a menudo hacen algo de magia para conectar las dependencias en forma de cascada, y probablemente no funcionen bien cuando algunas cosas son Ty otras son Func<T>.
  • Las funciones tienen cierta indirección, por lo que puede ser un poco más difícil razonar y depurar.
  • Las funciones retrasan la creación de instancias, lo que significa que los errores de tiempo de ejecución pueden aparecer en momentos extraños o no aparecer durante las pruebas. También puede aumentar las posibilidades de problemas de orden de operaciones y, dependiendo de su uso, puntos muertos en el pedido de inicialización.
  • Es probable que lo que pase al Func sea un cierre, con la leve sobrecarga y las complicaciones que conlleva.
  • Llamar a un Func es un poco más lento que acceder al objeto directamente. (No lo suficiente como para notarlo en cualquier programa no trivial, pero está ahí)
Telastyn
fuente
1
Utilizo IoC Container para crear la fábrica de una manera tradicional, luego la fábrica empaqueta los métodos de las interfaces en lambdas, evitando el problema que mencionó en su primer punto. Buenos puntos.
TheCatWhisperer
1

Tomemos un ejemplo simple: tal vez esté inyectando un medio de registro.

Inyectando una clase

class Worker: IWorker
{
    ILogger _logger;

    Worker(ILogger logger)
    {
        _logger = logger;
    }
    void SomeMethod()
    {
        _logger.Debug("This is a debug log statement.");
    }
}        

Creo que está bastante claro lo que está sucediendo. Además, si está utilizando un contenedor de IoC, ni siquiera necesita inyectar nada explícitamente, solo agregue a la raíz de su composición:

container.RegisterType<ILogger, ConcreteLogger>();
container.RegisterType<IWorker, Worker>();
....
var worker = container.Resolve<IWorker>();

Al depurar Worker, un desarrollador solo necesita consultar la raíz de la composición para determinar qué clase concreta se está utilizando.

Si un desarrollador necesita una lógica más complicada, tiene toda la interfaz para trabajar:

    void SomeMethod()
    { 
       if (_logger.IsDebugEnabled) {
           _logger.Debug("This is a debug log statement.");
       }
    }

Inyectando un método

class Worker
{
    Action<string> _methodThatLogs;

    Worker(Action<string> methodThatLogs)
    {
        _methodThatLogs = methodThatLogs;
    }
    void SomeMethod()
    {
        _methodThatLogs("This is a logging statement");
    }
}        

Primero, observe que el parámetro constructor tiene un nombre más largo ahora methodThatLogs,. Esto es necesario porque no se puede saber qué Action<string>se supone que debe hacer. Con la interfaz, estaba completamente claro, pero aquí tenemos que recurrir a confiar en la denominación de parámetros. Esto parece inherentemente menos confiable y más difícil de aplicar durante una compilación.

Ahora, ¿cómo inyectamos este método? Bueno, el contenedor de IoC no lo hará por usted. Por lo tanto, queda inyectado explícitamente cuando crea una instancia Worker. Esto plantea un par de problemas:

  1. Es más trabajo instanciar un Worker
  2. Los desarrolladores que intenten depurar Workerencontrarán que es más difícil descubrir qué instancia concreta se llama. No pueden simplemente consultar la raíz de la composición; Tendrán que rastrear el código.

¿Qué tal si necesitamos una lógica más complicada? Su técnica solo expone un método. Ahora supongo que podrías hornear las cosas complicadas en la lambda:

var worker = new Worker((s) => { if (log.IsDebugEnabled) log.Debug(s) } );

pero cuando estás escribiendo tus pruebas unitarias, ¿cómo pruebas esa expresión lambda? Es anónimo, por lo que su marco de prueba de unidad no puede instanciarlo directamente. Tal vez puedas encontrar una forma inteligente de hacerlo, pero probablemente será una PITA más grande que usar una interfaz.

Resumen de las diferencias:

  1. Inyectar solo un método hace que sea más difícil inferir el propósito, mientras que una interfaz comunica claramente el propósito.
  2. Inyectar solo un método expone menos funcionalidad a la clase que recibe la inyección. Incluso si no lo necesita hoy, puede que lo necesite mañana.
  3. No puede inyectar automáticamente solo un método utilizando un contenedor IoC.
  4. No puede distinguir desde la raíz de la composición qué clase concreta está funcionando en una instancia particular.
  5. Es un problema probar unitariamente la expresión lambda misma.

Si está de acuerdo con todo lo anterior, entonces está bien inyectar solo el método. De lo contrario, te sugiero que sigas con la tradición e inyectes una interfaz.

John Wu
fuente
Debería haber especificado, la clase que estoy haciendo es una clase de lógica de proceso. Se basa en clases externas para obtener los datos que necesita para tomar una decisión informada, una clase que induce efectos secundarios, tal registrador no se utiliza. Un problema con su ejemplo es que es una OO deficiente, especialmente en el contexto del uso de un contenedor de IoC. En lugar de usar una instrucción if, que agrega complejidad adicional, a la clase, simplemente se le debe pasar un registrador inerte. Además, la introducción de la lógica en las lambdas haría que las pruebas sean más difíciles ... derrotando el propósito de su uso, por lo que no se hace.
TheCatWhisperer
Las lambdas apuntan a un método de interfaz en la aplicación y construyen estructuras de datos arbitrarias en pruebas unitarias.
TheCatWhisperer
¿Por qué la gente siempre se enfoca en las minucias de lo que obviamente es un ejemplo arbitrario? Bueno, si insiste en hablar sobre el registro adecuado, un registrador inerte sería una idea terrible en algunos casos, por ejemplo, cuando la cadena a registrar es costosa de calcular.
John Wu
No estoy seguro de lo que quiere decir con "lambdas apunta a un método de interfaz". Creo que tendrías que inyectar una implementación de un método que coincida con la firma del delegado. Si el método pertenece a una interfaz, eso es incidental; no hay verificación de compilación o tiempo de ejecución para garantizar que así sea. ¿Estoy malentendido? ¿Quizás podrías incluir un ejemplo de código en tu publicación?
John Wu
No puedo decir que estoy de acuerdo con tus conclusiones aquí. Por un lado, debe acoplar su clase a la cantidad mínima de dependencias, por lo que el uso de lambdas garantiza que cada elemento inyectado sea exactamente una dependencia y no una combinación de varias cosas en una interfaz. También puede admitir un patrón de creación de una Workerdependencia viable a la vez, permitiendo que cada lambda se inyecte de forma independiente hasta que se satisfagan todas las dependencias. Esto es similar a la aplicación parcial en FP. Además, el uso de lambdas ayuda a eliminar el estado implícito y el acoplamiento oculto entre dependencias.
Aaron M. Eshbach
0

Considere el siguiente código que escribí hace mucho tiempo:

public interface IPhysicalPathMapper
{
    /// <summary>
    /// Gets the physical path represented by the relative URL.
    /// </summary>
    /// <param name="relativeURL"></param>
    /// <returns></returns>
    String GetPhysicalPath(String relativeURL);
}

public class EmailBuilder : IEmailBuilder
{
    public IPhysicalPathMapper PhysicalPathMapper { get; set; }
    public ITextFileLoader TextFileLoader { get; set; }
    public IEmailTemplateParser EmailTemplateParser { get; set; }
    public IEmaiBodyRenderer EmailBodyRenderer { get; set; }

    public String FromAddress { get; set; }

    public MailMessage BuildMailMessage(String templateRelativeURL, Object model, IEnumerable<String> toAddresses)
    {
        String templateText = this.TextFileLoader.LoadTextFromFile(this.PhysicalPathMapper.GetPhysicalPath(templateRelativeURL));

        EmailTemplate template = this.EmailTemplateParser.Parse(templateText);

        MailMessage email = new MailMessage()
        {
            From = new MailAddress(this.FromAddress),
            Subject = template.Subject,
            IsBodyHtml = true,
            Body = this.EmailBodyRenderer.RenderBodyToHtml(template.BodyTemplate, model)
        };

        foreach (MailAddress recipient in toAddresses.Select<String, MailAddress>(toAddress => new MailAddress(toAddress)))
        {
            email.To.Add(recipient);
        }

        return email;
    }
}

Toma una ubicación relativa a un archivo de plantilla, lo carga en la memoria, representa el cuerpo del mensaje y ensambla el objeto de correo electrónico.

Puede mirar IPhysicalPathMappery pensar: "Solo hay una función. Esa podría ser una Func". Pero en realidad, el problema aquí es que IPhysicalPathMapperni siquiera debería existir. Una solución mucho mejor es simplemente parametrizar la ruta :

public class EmailBuilder : IEmailBuilder
{
    public ITextFileLoader TextFileLoader { get; set; }
    public IEmailTemplateParser EmailTemplateParser { get; set; }
    public IEmaiBodyRenderer EmailBodyRenderer { get; set; }

    public String FromAddress { get; set; }

    public MailMessage BuildMailMessage(String templatePath, Object model, IEnumerable<String> toAddresses)
    {
        String templateText = this.TextFileLoader.LoadTextFromFile(templatePath);

        EmailTemplate template = this.EmailTemplateParser.Parse(templateText);

        MailMessage email = new MailMessage()
        {
            From = new MailAddress(this.FromAddress),
            Subject = template.Subject,
            IsBodyHtml = true,
            Body = this.EmailBodyRenderer.RenderBodyToHtml(template.BodyTemplate, model)
        };

        foreach (MailAddress recipient in toAddresses.Select<String, MailAddress>(toAddress => new MailAddress(toAddress)))
        {
            email.To.Add(recipient);
        }

        return email;
    }
}

Esto plantea una gran cantidad de otras preguntas para mejorar este código. Por ejemplo, tal vez solo debería aceptar un EmailTemplate, y luego tal vez debería aceptar una plantilla previamente renderizada, y luego tal vez debería estar alineado.

Es por eso que no me gusta la inversión de control como un patrón generalizado. En general, se presenta como esta solución divina para componer todo su código. Pero en realidad, si lo usa penetrante (en comparación con moderación), que hace que su código mucho peor por que le anima a introducir una gran cantidad de interfaces innecesarias que se utilizan totalmente al revés. (Hacia atrás en el sentido de que la persona que llama realmente debería ser responsable de evaluar esas dependencias y pasar el resultado en lugar de que la clase misma invoque la llamada).

Las interfaces deben usarse con moderación, y la inversión de control y la inyección de dependencia también deben usarse con moderación. Si tiene grandes cantidades de ellos, su código se vuelve mucho más difícil de descifrar.

jpmc26
fuente