Diseño de APIs con interfaces y extension methods

En varias ocasiones he dicho que uno de los mejores indicadores de la calidad de un framework o librería es la facilidad con la que se puede extender y he puesto varios ejemplos en mis posts sobre nhibernate, castle o log4net.

Al hablar de extender no me refiero exactamente a configurar. Normalmente cuando pensamos en configurar estamos contando con cambiar ciertos parámetros de trabajo o seleccionar distintos comportamientos entre un conjunto de opciones predefinidas, mientras que al extender lo que estamos haciendo es añadir nuevos comportamientos desarrollados por nosotros que no existían inicialmente.

Existen varias alternativas para crear puntos de extensión en una librería, desde el uso de eventos que nos permiten añadir comportamiento en determinados puntos del proceso hasta la sustitución completa de componentes. Para poder sustituir un componente por otro solemos utilizar polimorfismo y para ello en C# existen dos alternativas claras: clases bases e interfaces.

En ambos casos hay que encontrar un equilibrio entre la comodidad del consumidor del API y la comodidad del que extiende el API.

Por ejemplo, desde el punto de vista del consumir el API puede ser muy cómodo disponer de distintas sobrecargas de métodos pero para el que luego tiene que implementar el interface o la clase base, esto supone una carga de trabajo adicional que muchas veces es fácilmente evitable.

Veamos un caso concreto, el CommonServiceLocator del que ya he escrito antes. Para los que no lo conozcan, CommonServiceLocator pretende abstraer un contenedor de inversión de control usando un interface común y adapters para los contenedores más comunes.

El interface IServiceLocator contiene, entre otros, los siguientes métodos:

public interface IServiceLocator 
{
  object GetInstance(Type serviceType);
  object GetInstance(Type serviceType, string key);
  TService GetInstance<TService>();
  TService GetInstance<TService>(string key);
}

El método GetInstance tiene 4 sobrecargas, permitiéndonos usar una versión normal o genérica y pasar o no un argumento con el nombre de la dependencia. Esto implica que todas aquellas implementaciones que hagamos de IServiceLocator deberán incluir 4 métodos que, seguramente no hagan más que redirigirse llamadas unos a otros.

Para simplificar eso se puede crear una clase base que tenga una implementación parcial del interface y use un template method de forma que las clases derivadas sólo tenga que redefinir uno de los métodos. De hecho, eso es lo que hicieron los diseñadores de esta librería con la clase ServiceLocatorImplBase.

Lo malo de usar una clase base es que para poder aprovecharla obliga a las implementaciones a derivar de ella, y la herencia es un bien escaso en C# dónde sólo se puede heredar de una clase.

Una alternativa que cada vez me gusta más es usar extension methods para este tipo de casos. Así, en lugar de definir las 4 sobrecargas en el interface IServiceLocator, podemos definir un único método y dejar el resto como extension methods del interface:

public interface IServiceLocator 
{
  object GetInstance(Type serviceType, string key);
}

public static class ServiceLocatorExtensions
{
  public static object GetInstance(this IServiceLocator serviceLocator, Type serviceType)
  {
    return serviceLocator.GetInstance(serviceType, null);
  }

  public static TService GetInstance<TService>(this IServiceLocator serviceLocator)
  {
    return (TService)serviceLocator.GetInstance(typeof(TService), null);
  }

  public static TService GetInstance<TService>(this IServiceLocator serviceLocator, string key)
  {
    return (TService) serviceLocator.GetInstance(typeof (TService), key);
  }
}

La ventaja es que ahora las implementaciones del interface sólo necesitan implementar un método, pero los clientes del API siguen teniendo la posibilidad de usar todas las sobrecargas.

Además para conseguir esto no hace falta recurrir a herencia por lo que nuestra implementación del interface puede heredar de la clase que queramos.

En el caso de CommonServiceLocator es comprensible que no se usara esta técnica, fundamentalmente porque es un proyecto antiguo (de 2008), que se puede usar con .NET 2.0 y se liberó en una época en la que todavía mucha gente empleaba Visual Studio 2005, que no tenía soporte para extension methods.

La primera vez que vi un API diseñada así fue la de Ninject en donde la resolución de dependencias está diseñada tal y como está descrito en este post, usando extension methods. Actualmente es una técnica bastante extendida en todo tipo de liberías.

Como siempre, no hay soluciones mágicas que sirvan para todo y el uso de una clase base tiene sus ventajas porque permite incluir más lógica reutilizable por las clases derivadas, pero en general me gusta más la idea de poder minimizar al máximo el interface expuesto y añadir la funcionalidad de forma ortogonal tanto al interface como a sus implementaciones usando extension methods.

5 comentarios en “Diseño de APIs con interfaces y extension methods

  1. Hola Juanma,
    El ejemplo me parece muy bueno en el sentido de ver que las cosas se pueden hacer de múltiples maneras :)
    Es decir, si lo he entendido bien (seguro que no), el creador de la API opta por una interface con un sólo método y el mismo creador nos da un clase utilidades (perdón, quería decir métodos de extensión) donde hay múltiples métodos que terminan llamando al único método de la interface. Siendo así ¿Qué hemos ganado? Por un lado no obligamos a implementar n sobrecargas en la interface (que estoy de acuerdo puede ser un motivo para no implementarla o al menos que te dé pereza) pero sí obligamos a implementar un sólo método que tiene que responder a múltiples entradas (para soportar los métodos de extensión). Es cierto que como la interface sólo declara un método, si quiero paso de los métodos de extensión y petarán en run-time, pero… no sé, al final (y si el ejemplo lo he entendido) me parece incluso un poco más lioso este tema (porque el contrato está en métodos de extensión – que no voy a descubrir de forma sencilla – en vez de en la interface).
    ¿Qué opinas? ¿Me he liado? :)
    Gracias!

  2. Creo que te has liado un poco, o que mi yo del pasado no ha sabido explicarlo bien.

    Nunca va a petar nada en runtime, todo está fuerte y estáticamente tipado, tranquilo ;-)

    Los extension method ya existen, no hay que hacer nada cuando creas nuevas implementaciones del interface. Si las nuevas implementaciones implementan (valga la redundancia) correctamente el interface y no violan el principio de sustitución de Liskov, la forma en que están implementados los extension method será válida para ellas, por lo que el comportamiento estará disponible.

    La mayor pega que tiene esta técnica es que hace más complicado descubrir lo que puedes hacer (penaliza la «discoverability»), porque sólo leyendo el código del interface no sabes qué extension methods existen. Se puede mitigar usando el mismo namespace (e incluso el mismo fichero) para el interface y los extension methods, así cuando añadas el using para el interface tendrás también los extension methods y el intellisense te irá guiando.

  3. OK, tranquilo, tu yo del pasado se explicó bien, yo creo al final los 2 estamos hablando de lo mismo. :)
    Lo que me cuesto un poco es enfrentarme a la implementación de una interface con un sólo método cuando en realidad será llamado por n sobrecargas (los métodos de extensión). Por eso decía que (y podría pasar) implementara el método de la interface sin «conocer/descubrir» esas sobrecargas y por eso petara en run-time, porque no tuve en cuenta el caso de la sobrecarga X por ejemplo, no la conocía! :(
    Pero vamos, que está entendido y es una solución válida, eso está claro!!

  4. Las «sobrecargas» están usando el API pública del interface, que es algo que podría hacer cualquier otro cliente del interface (no pienses en los extension methods, sino en cualquier cliente normal).

    Si se te rompe en tiempo de ejecución, es que has implementado mal el interface ;-)

    De todas formas, lo que planteas es otro problemas diferente (e interesante) que es la limitación de los sistemas de tipos. Por muy rígidos y potentes que sean, llega un momento en que no puedes (o no resulta práctico) modelar con tipos todo el comportamiento y las signaturas no bastan para garantizar la corrección.

  5. Ivan Montilla dijo:

    Sé que este post es de 2012, y que por entonces, C#8 ni se olía, pero creo que la nueva característica que permite implementar métodos por defecto en interfaces, puede ser la mejor solución para el ejemplo del post. Por un lado, no es necesario implementar más que un método, y por otro, no pierdes discoverity como en los métodos de extensión.

Comentarios cerrados.