Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 17 de septiembre de 2019
.NET CoreComo probablemente sabréis, C# 8 hace posible que las interfaces incluyan una implementación por defecto para sus miembros... ¿Pero no habíamos quedado en que las interfaces definían contratos, pero no implementaciones? ¿El mundo se ha vuelto loco?

Pues sí, y creo que no del todo, respectivamente ;)

En este post vamos a echar un primer vistazo a la que creo que es una de las características más controvertidas de la nueva versión del lenguaje.
Nota: aún estamos usando compiladores y tooling preliminar, por lo que lo dicho aquí podría resultar incompleto o inexacto cuando la versión definitiva de C# 8 sea lanzada (en pocos días, vaya ;)

Las cosas de la evolución

El software evoluciona, es un hecho. Y a veces los componentes necesitan ser actualizados para mejorar las funcionalidades existentes o dotarlo de nuevas características. ¿Y cuál es el problema? Pues que esto no siempre es posible sin romper código existente.

Por ejemplo, imaginad que, con el tiempo, una interfaz conocida y ampliamente utilizada como IDisposable necesitase, para responder a una nueva necesidad, añadir una sobrecarga al método Dispose() como la mostrada a continuación:
public interface IDisposable
{
    void Dispose();
    void Dispose(bool inmediate);
}
Obviamente, si el equipo de diseño de .NET decidiera hacer algo parecido, estaría condenando a todos los desarrolladores (inclusive ellos mismos) a implementar dicha sobrecarga en las clases que usen IDisposable pues, de no ser así, las aplicaciones que lo utilizaran explotarían en compilación (o incluso en ejecución, en algunos casos). Vaya, que sería un breaking change en toda regla.

Entonces, ¿cómo conseguimos añadir miembros a interfaces sin romper nada?

Venga, evolucionemos, pero con cuidado...

Aquí es donde entra en juego esta novedad introducida en C# 8. La idea es que podamos evolucionar interfaces introduciendo en ellas un comportamiento por defecto para los nuevos miembros, que será utilizado en aquellas clases que implementen la interfaz pero no aporten código para los mismos.

Volviendo al caso anterior, la nueva versión de IDisposable quedaría de la siguiente manera:
public interface IDisposable
{
    void Dispose();
    void Dispose(bool inmediate) => Dispose();
}
Fijaos que en esta nueva definición de IDisposable estamos introduciendo un nuevo miembro Dispose(bool) en cuyo código simplemente llamamos al Dispose() original, de forma que aseguramos la retrocompatibilidad para clases que implementen la interfaz:
  • Las clases que incluyan su propia versión de Dispose(bool), utilizarán este método en lugar de la implementación por defecto.
  • En otros casos, se ejecutará la implementación por defecto proporcionada desde la interfaz.
Pero es importante tener en cuenta un detalle: las implementaciones por defecto sólo serán accesibles a través de las interfaces que las definen, y no de las clases que las usan. Esto se ilustra en el siguiente bloque de código:
public interface IAnimal
{
    void Info() => Console.WriteLine("I'm an animal");
}

public class Cat : IAnimal
{
    public string Name { get; }

    public Cat(string name)
    {
        Name = name;
    }
}

class Program
{
    static void Main(string[] args)
    {
        Cat garfield = new Cat("Garfield");
        IAnimal felix = new Cat("Felix");

        garfield.Info(); // Compilation error: Info() is not a member of Cat
        felix.Info();    // Ok: Info() is a member of IAnimal --> "I'm an animal"
    }
}
Esto tiene bastante sentido: el método Info() no está implementado en la clase, por lo no podemos acceder a él. Si queremos hacerlo, debemos informar al compilador de que se trata del Info() definido en la interfaz ICat.

Sin embargo, si la clase implementa su propia versión del método definido en la interfaz, será éste el utilizado en tiempo de ejecución:
public interface IAnimal
{
    void Info() => Console.WriteLine("I'm an animal");
}

public class Cat : IAnimal
{
    public string Name { get; }

    public Cat(string name)
    {
        Name = name;
    }

    public void Info() => Console.WriteLine($"I'm {Name} the cat");
}

class Program
{
    static void Main(string[] args)
    {
        Cat garfield = new Cat("Garfield");
        IAnimal felix = new Cat("Felix");

        garfield.Info(); // Ok: Info() is a member of Cat --> "I'm Gardfield the cat"
        felix.Info();    // Ok: Info() is a member of IAnimal --> "I'm Felix the cat"
    }
}

¿Y no se parece esto mucho a una clase abstracta?

Pues sí, un gran aire sí que tiene, e incluso diría que si abusamos de esta característica podríamos poner en apuros la división que tradicionalmente existía entre estos dos tipos de elementos. Técnicamente, podría haber casos en los que podríamos usar interfaces o clases abstractas indistintamente y el resultado sería idéntico.

Pero creo que la cuestión es no olvidar para qué sirve cada cosa. Una clase abstracta es, al fin y al cabo, una clase; puede tener constructores, destructores, gestionar su estado o heredar de otra clase. En la práctica, se trata sólo de una implementación que se deja a medias para ser completada posteriormente por clases concretas haciendo uso de la herencia.

Una interfaz continúa siendo una especificación, un contrato que cumplen las clases que la implementan, aunque gracias a esta nueva característica pueda incluir código por defecto en determinados miembros, ya sea para simplificar escenarios propios de la evolución como los que comentábamos antes, o para encapsular determinados comportamientos.

Esta encapsulación de comportamientos es un acercamiento de C# a conceptos ya existentes en otros lenguajes, como mixins o traits, que proponen la composición y extensión de clases de una forma ligeramente diferente al uso de la tradicional herencia. Resumidamente, la idea es poder definir unidades de comportamiento reutilizables que puedan ser luego aplicadas a clases sin necesidad de utilizar herencia; o en otras palabras, si una clase implementa (o incluye, más bien) un trait, automáticamente tendremos en ella todos los comportamientos definidos en el mismo.

Mmmmm... entonces, ¿es esto una puerta abierta a la herencia múltiple?

Bueno, dado que las interfaces permiten la implementación de traits, y además soportan herencia múltiple, de alguna forma podríamos considerar que sí, se abre la posibilidad de usar herencia múltiple en clases, aunque aún de forma bastante limitada. Digamos que en vez de una puerta abierta sería una rendija ;)

Vamos a ilustrarlo con un pequeño ejemplo donde usaremos la implementación por defecto en interfaces para crear una ilusión de herencia múltiple:
public interface ICat
{
    void Info() => Console.WriteLine("I'm a cat");
    void Meow() => Console.WriteLine("Meow!");
}

public interface IDog
{
    void Info() => Console.WriteLine("I'm a dog");
    void Woof() => Console.WriteLine("Woof!");
}

// It seems that some animals can be cat & dogs at the same time...
public interface IDoublePersonalityAnimal: ICat, IDog { }

// And here it is:
public class DoublePersonalityAnimal: IDoublePersonalityAnimal { }

class Program
{
    public static void Main()
    {
        IDoublePersonalityAnimal animal = new DoublePersonalityAnimal();

        animal.Info(); // Compilation error: Info() is defined in both ICat & IDog

        animal.Meow();         // Meow!
        ((ICat)animal).Info(); // I'm a cat

        animal.Woof();         // Woof!
        ((IDog)animal).Info(); // I'm a dog
    }
}
Observad lo siguiente:
  • La interfaz IDoublePersonalityAnimal hereda de ICat e IDog, por lo que, en teoría, hereda los métodos Info(), Meow() y Woof() con sus respectivas implementaciones por defecto.
  • Invocar a Info() da un error de ambigüedad, porque tanto ICat como IDog lo implementan.
  • Para acceder a las implementaciones específicas de cada interfaz, debemos hacer el cast apropiado, de forma que el compilador sabrá a qué método nos referimos.
Este enfoque es el denominado most specific override rule, la solución para el famoso diamante de la muerte, un problema clásico derivado del uso de herencia múltiple.

¿También modificadores de acceso en interfaces? ¿Pero esto qué es?

Hasta ahora, todos los miembros declarados en una interfaz eran públicos, no tenía sentido que fuera de otra forma. Pero derivado de los cambios anteriores, tiene lógica que también se haya introducido en las interfaces la posibilidad de aplicar modificadores de acceso. De hecho, según la especificación, se permiten los siguientes modificadores: private, protected, internal, public, virtual, abstract, sealed, static, extern, y partial.

Observad el siguiente código, donde vemos un miembro privado en la interfaz; se trata de un helper, un método de utilidad utilizado internamente en las implementaciones por defecto de los métodos públicos con el fin de estructurar o simplificar el código:
public interface ICat
{
    void Info() => Write("I'm a cat");
    void Meow() => Write("Meow!");

    protected void Write(string message) => Console.WriteLine(message);
}
Obviamente, el método privado Write() no es visible fuera de esta interfaz. Pero podríamos conseguirlo si en lugar de ser private fuera protected, lo cual podría ser de utilidad en jerarquías de interfaces como la siguiente:
public interface ICat
{
    void Info() => Write("I'm a cat");
    void Meow() => Write("Meow!");

    protected void Write(string message) => Console.WriteLine(message);
}

public interface IBigCat: ICat
{
    void Info() => Write("I'm a big cat");
    void Meow() => Write("MEEEOOOOW!!!");
}
Los miembros protected sólo serán visibles desde interfaces que hereden de ICat, pero no desde clases que implementen alguna de estas interfaces.
Por defecto, todos los miembros son public, y los métodos con implementación por defecto son considerados virtual si no se especifica lo contrario.

En definitiva...

Creo que con lo visto hasta el momento ya podemos hacernos una idea de por dónde van los tiros, pero es pronto para ver todas las posibilidades y problemas que traerá esta nueva característica, que ciertamente apunta alto ;)

En cualquier caso, no debemos perder de vista que, oficialmente, los motivos de la inclusión de esta característica en C# 8 son los siguientes:
  • Permitir la extensión futura de interfaces proporcionando compatibilidad, tanto a nivel de código como binaria, con software existente.
  • Interoperar con APIs propias de Android e iOS, donde existen conceptos como default methods de Java y protocols de Swift.
  • Proporcionar a C# soporte para traits o mixins.
Aunque, como siempre, el uso de las implementaciones por defecto podría causar problemas si la usamos incorrectamente o si abusamos de ella, si la entendemos en el contexto de los problemas que pretende solucionar, la idea no es mala.

La posibilidad de extender interfaces sin romper código parece bastante atractiva, y está bien resuelta con esta nueva característica del lenguaje. Sin duda hará más sencilla la evolución de los frameworks.

Y por otra parte, creo que la inclusión de traits en C# proporcionará fórmulas de abstracción y reutilización de código bastante potentes. Lo único que me suena raro a este respecto es que la palabra clave interface de C#, tradicionalmente unida al concepto "contrato" o "especificación", oculta sus nuevas posibilidades. No sé, quizás si en lugar de interface se utilizara una nueva keyword como trait o similar, quedaría más clara la intencionalidad... pero bueno, entiendo también que la migración es más suave dejándolo así, porque los conceptos pueden asimilarse bastante.

En fin, estas son las primeras impresiones, pero seguro que en el futuro tendremos más al ir profundizando y aprendiendo sobre esta nueva característica de nuestro lenguaje favorito ;)

Publicado en Variable not found.

2 Comentarios:

LuigiMX dijo...

Sería muy interesante un artículo sobre lo que viene para C# 9.0, cosas como Shapes me parecen que van a ser muy grandes.

José María Aguilar dijo...

Hola!

Sí, C# 9 parece que vendrá cargadito de regalos, esto es un no parar ;D

Seguro iremos viéndolo conforme progrese la versión y podamos "tocar" las novedades :)

Saludos!