Inicio > .NET Framework, Apunte, C# > Conceptos OOP: jerarquía de clases

Conceptos OOP: jerarquía de clases


Cuando era pequeño y estaba aburrido -casi siempre- me inventaba un juego, el cual consistía en que andaba por la casa buscando objetos hasta que escogía una característica de algo que me gustaba: el color de algo, su textura, su olor… y con base en esa característica me ponía a comparar los objetos que tenía a mi alrededor, y los diferenciaba entre sí. Veía que característica era la que ganaba al final.

En una ocasión, para desmayo de mis padres, la característica fue la resistencia de los objetos a un impacto tras una trayectoria de varios metros. Descubrí que las jarras, floreros y la colección de discos de vinil no eran resistentes…

Durante mi infancia, siempre pensé que era un juego ingenioso, hasta que me di cuenta que es un proceso natural de cualquier ser humano, y que posiblemente es el que nos distingue del resto de la fauna de este planeta: la clasificación de objetos.

Clasificando al mundo

En efecto, la clasificación de un objeto requiere un nivel de abstracción. Clasificar significa tomar un atributo común de un conjunto de objetos, y compararlos con otros objetos. Así, por ejemplo, tenemos una mesa de madera, una jarra con agua y una llanta de auto. Yo podría decir que tanto la mesa como la botella son artículos domésticos que se encuentran en cocinas, y diferenciarlo de la llanta, la cual no es un artículo doméstico. En este caso, estoy clasificando los objetos por su utilidad en el hogar. Claramente la mesa y la jarra son útiles en el hogar, y se espera que estén ahí por lo mismo. Sin embargo, si entro a la cocina y veo una llanta sobre la barra, no me hará mucho sentido: una llanta no tiene sentido en un hogar.

La clasificación está extendidísima en el quehacer humano. Los biólogos clasifican a los seres vivos en reinos. Los geólogos clasifican las eras de la Tierra, los historiadores clasifican los sucesos por períodos o etapas, los literatos clasifican las novelas por movimientos literarios, e incluso los jóvenes de hoy (snif) clasifican su música por género.

Esa capacidad de abstracción que tenemos nos da muchísimas ventajas, puesto que las clasificaciones nos permiten tratar objetos con atributos similares (y por tanto, dentro de una clasificación determinada) de forma idéntica. Así, sabemos que los mamíferos tienen ciertos comportamientos comunes, como la sociabilidad y la protección mutua. Por tanto, respecto a esto, podemos establecer lineamientos generales sin importar si tratamos con perros, leones o humanos.

Por otra parte, también podemos abstraer comportamientos. Por ejemplo, todos los mamíferos tienen el comportamiento de comer, aunque la forma de hacerlo varíe: el león probablemente cazará a su presa y la comerá cruda, mientras que el perro esperará a que su dueño le alimente con croquetas y huesos; el humano probablemente irá a algún restaurante. Pero el comportamiento es el mismo: el comportamiento aplica al sentido o intención de la acción, no tanto a cómo ésta se lleva a cabo. Otro ejemplo: todos los animales tienen el comportamiento de respirar. Por tanto, dado que un pez, un cangrejo y una gaviota son animales, podemos asegurar que respiran. Sin embargo, el pez respira separando el oxígeno del agua que le rodea, mientras que la gaviota toma el oxígeno disuelto del aire. El cangrejo hace ambos procesos, dependiendo de si se encuentra en tierra o mar.

Clasificando problemas

La abstracción y clasificación parecen ser una herramienta poderosa. De hecho lo es tanto que la usamos también para la resolución de problemas de todo tipo en nuestra vida cotidiana.

Pensemos en algún ejemplo. Un encargado de una fábrica de refrescos tiene que realizar diversas tareas. El área de planeación estratégica puede solicitarle que produzca más refrescos. El área de ventas puede solicitarle que le envíe refrescos a tal o cual supermercado. El área de calidad puede solicitarle que algún lote de refrescos sea destruido, pues no pasó la prueba de control. Y por último, el área de almacén puede solicitarle que traslade un lote de refrescos de una ubicación a otra. ¿Qué pueden tener estas tareas en común?

1.- Todas las tareas implican hacer algo con refrescos. Los refrescos están agrupados por sabores y presentaciones, y por lotes (i.e. fecha de creación).

2.- Todas las tareas implican un movimiento contable de almacén. entrada, salida a tienda, salida a desperdicio y movimiento interno.

3.- Todas las tareas conllevan un movimiento financiero: crear refrescos implica que se gastará materia prima, por lo que el movimiento será un costo. Lo mismo el destruir un lote porque no pasó las pruebas de calidad. Por otra parte, la venta al supermercado implica un movimiento positivo, pues entrará dinero a la fábrica por concepto de ventas. Por último, el movimiento entre almacén genera un movimiento neutral, pues no se gasta ni recibe nada.

4.- Todas las tareas tienen una persona quien la solicita y se hace responsable, y otra que la ejecuta y se hace responsable de llevarla a cabo.

5.- Todas las tareas deben realizarse en un período contable válido y en horarios laborales válidos.

Supongo que hay más, pero creo que con esas podemos ilustrar el tema. Si nosotros fuéramos el encargado de la fábrica, ¿qué podríamos hacer? Si no clasificamos, tendríamos que generar un proceso independiente para cada tarea. Pero no es el caso, podemos reutilizar si clasificamos bien.

De entrada, consideremos cada una de las tareas como una orden. La orden debe tener tres propiedades: el nombre de quien la solicita y el nombre de quien es responsable de ejecutarla (por el punto 4). De igual forma, debe tener una fecha de inicio y una fecha de fin (por 5). Por otra parte, deberá tener un objeto Inventario. El objeto Inventario es en realidad una lista que lleva el nombre de un refresco, su presentación y lote, y su cantidad. Todas las órdenes tienen esta lista, por tanto todas tienen un inventario. Finalmente, todas las órdenes deberán tener un comportamiento similar, llamado "hacer movimiento contable" y "hacer movimiento financiero".

clip_image001

Ya con esto, podemos decir que tenemos cuatro tipos de órdenes: de trabajo, órdenes de venta, orden de salida a desperdicio y orden de traslado. Comparten ciertas propiedades comunes, como el inventario o los responsables. Pero es evidente que hacer un movimiento contable y hacer un movimiento financiero tienen significados diferentes. Es decir, para los cuatro significa que van a afectar el inventario del almacén, y que van a afectar las finanzas de la fábrica. Pero para una orden de trabajo, significa que tendrán que gastar dinero en la producción , pero que ingresará nuevo producto al almacén; para una orden de venta es lo contrario: sale producto del almacén pero ingresa dinero a las arcas de la fábrica; la orden de salida a desperdicio es doble pérdida: sale material del almacén y no entra dinero en la billetera de la fábrica; por último, la orden de traslado mueve material, pero no sale ni entra, y no tiene movimiento financiero.

El haber hecho esta clasificación ayudará en mucho al encargado de la fábrica. Por ejemplo, seguramente tendrá que crear un formato para llevar el registro. Si no hubiera clasificado habría hecho cuatro formatos: uno por cada tarea. Pero ahora sabe que puede usar el mismo formato con los responsables, las fechas y el inventario. Ahora, quizás tenga que crear una hojita para llevar el registro especial de cada orden, el equivalente a "hacer movimiento contable" y "hacer movimiento financiero", pues seguramente tendrá que recolectar diferentes firmas, etc. Respecto a políticas y procedimientos, podrá crear las generales, y sólo las particulares para cada una de ellas, en lugar de crear una política y procedimiento para cada orden. Y así sucesivamente.

Clasificando bits

Hasta ahora hemos visto puros ejemplos de la vida real, aplicados a objetos físicos y a escenarios de uso en un negocio hipotético. Pero ¿y eso qué tiene que ver con la programación?

Para contestar esa pregunta, tenemos que hacer un poquito de historia de la computación. Recordemos que el propósito de una computadora es computar. Es decir, realizar operaciones que cuantifiquen. Entre estas operaciones, podemos computar valores lógicos y relacionarlos (i.e. álgebra booleana). Y gracias a esto, podemos realizar interpretaciones de hardware, lo cual se traduce en una instrucción a algún dispositivo. Un conjunto de instrucciones, una tras otra, forman un programa.

Lo que desprende de lo anterior es que un programa es un conjunto secuencial de instrucciones. Podemos tener unas diez instrucciones secuenciales, y la computadora lee la primera, la ejecuta y devuelve un resultado; lee la segunda, la ejecuta y devuelve un resultado; lee la tercera… y así sucesivamente. Es perfectamente natural para una computadora: lo único que necesita es tener las sentencias, el hecho de que sean secuenciales es parte inherente a su naturaleza: las computadoras no pueden pensar de otra forma. Se comen una sentencia, escupen el resultado y van por la siguiente.

Así las cosas, pues es perfectamente natural y entendible que durante años, cuando surgieron los lenguajes de programación, éstos se amoldaran a la forma de pensar de una computadora. Lenguajes como Cobol, Fortran y C escriben instrucción tras instrucción, una tras otra. Esto hizo imposible que el mismo código pudiera reutilizarse en más de un lugar. Esta necesidad provocó que salieran lenguajes estructurados, que permiten escribir subrutinas parametrizadas. Pero a pesar de esto, el hecho sigue siendo el mismo: instrucción tras instrucción, todo secuencial: llamar a una subrutina tras otra, etc.

El problema de pensar en instrucción tras instrucción es que tras miles de líneas de código, comienza a hacerse difícil de administrar, y por tanto, el código se vuelve propenso a errores. Pero lo peor es que esta forma de pensar no es natural en un humano. ¡Pues claro: si nosotros pensamos clasificando! Para nosotros sería mucho más natural enfocar los problemas de software mediante clasificaciones, en lugar de ejecutar tareas una tras otra. Estas necesidades son las que han provocado que surgieran los lenguajes orientados a objetos.

En otras palabras, los lenguajes orientados a objetos deben permitir realizar clasificaciones. Por supuesto, los lenguajes modernos como Java o C# permiten mucho más que crear clasificaciones, pero es el motivador principal. En C# se clasifica creando -tatatáaaan- clases. ¡Así es! Las clases y estructuras, y en general los tipos de datos, son las herramientas mediante las cuales creamos nuestras clasificaciones. Sin embargo, crear una clase no es suficiente, pues éstas, por sí mismas, no nos permiten crear relaciones de identidad.

Relaciones de identidad

Cuando hablábamos de los objetos y sus atributos y comportamientos, y cómo clasificarlos, pasamos por algo un concepto muy importante: las relaciones. Aunque las mencionamos brevemente no ahondamos mucho en ellas. Pues bien, es el momento de hacerlo.

Para poder clasificar, tenemos que comparar. Y para comparar, tenemos que relacionar propiedades y atributos, si no, no podríamos llevar a cabo la clasificación. Así, es importante que nos detengamos un momento para estudiar este concepto.

El primer tipo de relación, quizás el más sencillo, es el de asociación. La relación de asociación quiere decir que un objeto conoce a otro. Por ejemplo, un vaso debe conocer a una mesa, pues es donde se posa. Una mesa, sin embargo, no necesariamente conoce un vaso.

El segundo tipo de relación, más interesante, es el de la agregación. Es una relación de "x contiene un y". Por ejemplo, una computadora contiene un monitor, un teclado y un ratón. Otro tipo de relación, similar a la agregación, es la composición. Ésta es una relación de "x está formado por a, b y c". Por ejemplo, un automóvil está formado por un motor, unas llantas, un chasis, etc.

El último tipo de relación importante es el de la identidad. Esta relaciona las propiedades de un objeto con otro y crea generalizaciones. Es una relación de tipo "x es un tipo de y". Por ejemplo, un auto es un vehículo, un autobús es un vehículo, y una motocicleta es también un vehículo. Por otra parte, un vehículo es una máquina, una computadora es una máquina. Por lo tanto, un automóvil es una máquina. Y así sucesivamente.

Las relaciones de identidad quedan como anillo al dedo para todo lo que hemos visto. En efecto, podemos ver claramente que la clasificación que podemos hacer sobre ciertos objetos queda complementada cuando podemos hacer relaciones "x es un tipo de y". Más aún, según podemos ver en el ejemplo anterior, conforme establecemos relaciones de identidad, éstas se trasladan hacia arriba y hacia abajo, y entonces decimos que hemos creado una jerarquía. Ejemplo:

clip_image002

El ejemplo anterior es un poco somero, pero ilustra relaciones de identidad y una jerarquía de clases. Los animales, plantas y hongos son seres vivos, así que siempre que hablemos de un ser vivo, inferimos que es alguno de estos tres (por lo menos). Comportamiento común de los seres vivos: vivir, reproducir, comer, morir. Luego, un insecto, un mamífero, un ave, molusco o pez son un tipo de animal. Los homínidos, canes y felinos son un tipo de mamífero. Un humano es un tipo de homínido, y un león, tigre y un gato son tipos de felinos.

Pues bien, ya con clasificación y con relaciones de identidad entendidas, podemos regresar a C#. En .NET, las relaciones de identidad se hacen mediante la herencia.

Herencia en C# y .NET

El concepto de herencia, que no es otra cosa que establecer una relación de identidad entre dos clases, es medular en la plataforma .NET y en general, en cualquier plataforma / lenguaje de programación orientada a objetos. Junto con la encapsulación y el polimorfismo, conforman los pilares de la misma.

En .NET cada lenguaje decide cómo implementar la herencia. En el caso de C#, se hace poniendo dos puntos tras el nombre de la clase, seguido del nombre de la clase con la cual queremos establecer la relación.

class ClaseDerivada : ClaseBase
{
    …
}

Decimos que la clase con la que relacionamos es una clase base de la actual, a la cual se le conoce como clase derivada. También se les conoce como clase generalizada y clase especializada, respectivamente.

En C# y .NET, una clase tiene una y sólo una relación de identidad (si una clase no especifica su clase base, ésta en automático será la clase System.Object). En C++, por ejemplo, una clase puede tener cero, una o múltiples relaciones de identidad. A esta característica se le conoce como herencia múltiple. Las razones por las que C# no implementa herencia múltiple es que ésta causa problemas cuando no se implementa bien (lo cual lo convierte en un problema de un programador no muy hábil, más que una falla del lenguaje… pero bueno). Asimismo, la herencia siempre es pública (es decir, todos los métodos públicos se heredan como públicos), en contraste con C++ donde la herencia puede ser pública, protegida o privada.

Por supuesto, al momento de heredar, heredamos métodos y propiedades y atributos protegidos y públicos.

class ClaseBase
{
    public void Foo() { }
    public string Goo { get; set; } 
}

class ClaseDerivada : ClaseBase
{
    public void Hoo() { }
}

ClaseDerivada c = new ClaseDerivada();
c.Foo();
c.Goo = "Goo";

c.Hoo();

En este tenor, podemos hacer uso de la relación de la siguiente forma:

ClaseBase b = new ClaseDerivada();
b.Foo();
b.Goo = "Goo";

b.Hoo(); // no compila

Dado que ClaseDerivada es un tipo de ClaseBase, podemos asignar a una variable de tipo ClaseBase una instancia de ClaseDerivada. Podemos invocar al método Foo y a la propiedad Goo, puesto que ClaseBase las define. No podemos, sin embargo, invocar al método Hoo porque esa es propia de ClaseDerivada.

La herencia de clase puede seguir y seguir y seguir:

class SegundaClaseDerivada : ClaseDerivada { … }

class TerceraClaseDerivada : SegundaClaseDerivada { … }

class CuartaClaseDerivada : TerceraClaseDerivada { … }

Conforme vamos creando especializaciones, decimos que vamos creando una jerarquía de clases. Por supuesto, no importa qué tan profunda sea la jerarquía, la relación siempre se mantiene:

ClaseBase b = new CuartaClaseDerivada();
b.Foo(); // ok

b.Goo = "Goo"; // ok

A veces querremos que una clase ya no pueda seguir heredándose. En estos casos, decimos que la clase es final, o que está sellada. Para sellar una clase, usamos la palabra reservada "sealed" en C#. Cualquier intento por heredar de una clase sellada, generará un error de compilación.

sealed class QuintaClaseDerivada : CuartaClaseDerivada { … }

class SextaClaseDerivada : QuintaClaseDerivada { … } // error de compilación

Hay ocasiones en las que queremos crear clases que sólo sirvan como base. Es decir, que puedan ser heredadas, pero no instanciadas. A estas clases las llamamos abstractas, y se marcan con la palabra reservada "abstract".

abstract class ClaseBase { … }
class ClaseDerivada : ClaseBase { … }

ClaseDerivada d1 = new ClaseDerivada(); // ok
ClaseBase b1 = new ClaseDerivada(); // ok

ClaseBase b2 = new ClaseBase(); // error de compilación

 

Comportamientos y contratos

Como decíamos arriba, una clase abstrae comportamientos y atributos. Esto, traducido a C#, no es otra cosa que métodos y propiedades. Un conjunto de métodos define un comportamiento: esto es, de qué forma se modifica el estado de un objeto. Ahora bien, ¿qué pasa cuando queremos abstraer varios comportamientos? Los comportamientos en general definen cómo se interactúa y maneja el estado de un objeto. Pongamos un ejemplo para entender la pregunta anterior.

class Point {
    public int X { get; set; }
    public int Y { get; set; }

    public static readonly Zero;

    public Point(int x, int y) {
        X = x;
        Y = y;
    }

    static Point() {
        Zero = new Point(0, 0);
    }
}

class Line {
    public Point Start { get; set; }
    public Point End { get; set; }
    
    public Line(Point start, Point end) {
        Start = start;
        End = end;
    }
}

class Circle {
    public Point Center { get; set; }
    public int Radius { get; set; }

    public Circle(Point center, int radius) {
        Center = center;
        Radios = radios;
    }
}

Bien, tenemos unas tres clases que representan objetos en un plano euclídeo: un punto, una línea y un círculo. Dado que son figuras, podríamos pensar que existe una clase base, llamada Shape, y que ésta puede agrupar comportamientos comunes. Pongamos por ejemplo que queremos tener un mecanismo para comparar dos objetos y saber si son iguales. Podemos crear un método, llamado IsEqualTo, que tome como parámetro un Shape y regrese true cuando son iguales y false en caso contrario.

class Shape {
    public abstract bool IsEqualTo(Shape other);
}

class Point : Shape {
    …
    public override bool IsEqualTo(Shape other) {
        bool equals = false;
        Point pt = other as Point;
        if (pt != null) {
            equals = X == pt.X && Y == pt.Y;
        }
        return equals;
    }
}

class Line {
    …
    public override bool IsEqualTo(Shape other) {
        bool equals = false;
        Line ln = other as Line;
        if (ln != null) {
            equals = Start.IsEqualTo(ln.Start)
                  && End.IsEqualTo(ln.End);
        }
        return equals;
    }

}

class Circle {
    …
    public override bool IsEqualTo(Shape other) {
        bool equals = false;
        Circle cc = other as Circle;
        if (cc != null) {
            equals = Center.IsEqualTo(cc.Center)
                  && Radius == cc.Radius;
        }
        return equals;
    }
}

De esta forma, a través e la jerarquía de clases, hemos asegurado un comportamiento: todas las figuras que hereden de Shape pueden ser comparables entre sí. Gracias a esto, podemos implementar algoritmos genéricos para la clase Shape, como por ejemplo, un método que busque en los elementos de un Array o List de objetos Shape, y que pueda utilizarse para cualquiera de las tres clases.

static class Search {
    public static bool Exists(List<Shape> shapes, Shape shapeToFind) {
        foreach (Shape shape in shapes) {
            if (shape.IsEqualTo(shapeToFind))
                return true;
        }
        return false;
    }
}
…

List<Shape> shapes = new List<Shape>();
shapes.Add(new Point(10, 15));
shapes.Add(new Line(new Point(42, 42), new Point(25, -14)));
shapes.Add(new Circle(new Point(17, 15), 12));
shapes.Add(new Point(42, 42));

bool exists = Search.Exists(new Point(42, 42));

Console.WriteLine("Punto 42, 42 encontrado: {0}", exists);

Sin embargo, esto no es suficiente. Resulta que yo tengo, aparte, una clase Color, que permite establecer el color de una figura (Shape).

class Color
{
    public R { get; set; }
    public G { get; set; }
    public B { get; set; }

}

Evidentemente un Color no es una figura, por lo que no podemos establecer una relación de identidad (y por tanto no podemos heredar de Shape: hacerlo sería un error de diseño). Pero a todas luces, un color también puede ser comparable entre sí:

class Color 
{
    public R { get; set; }
    public G { get; set; }
    public B { get; set; }

    public bool IsEqualTo(Color c)
    {
        bool equals = false;
        if (c != null) 
            equals = R == c.R && G == c.G && B == c.B;

        return equals;
    }

}

Con esto vemos algo esencial: a pesar de las diferencias existentes entre Shape (y sus derivadas) y Color, ambas definen un mismo comportamiento: ambas pueden compararse.

Regresando a mi método de búsqueda… como éste se hizo para Shape, ya nos amolamos. Podríamos hacer que Color herede de Shape, pero no tiene sentido, porque no hay realmente una relación de identidad. Podríamos cambiar al método Exists para que tome un object, y hacer la conversión correspondiente entre Shape, y si falla, luego a Color… el problema de este enfoque es que si tenemos una tercera clase base: Pencil, que también sea comparable, pues ahora tendremos que triplicar el código de Exists. Y de todas formas no sería nada extensible.

Otro enfoque para solucionar el problema sería crear una clase base, Comparable, de las cuales hereden tanto Color como Shape.

class Comparable {
    public abstract bool IsEqualTo(Comparable c);
}

class Shape : Comparable { … }

class Color : Comparable { … }

Esto podría resolver el problema momentáneo. Pero… supongamos que aparte del comportamiento de comparabilidad, queremos añadir cualquier otro comportamiento, que no lo compartan Shape y Color, pero sí Color y Pen? Nuestra jerarquía de clases se convertiría en una sumamente compleja, difícil de gestionar, y en resumen terminaríamos duplicando código y con un mal diseño entre manos. Y encima, sólo podemos tener una clase base, pues la herencia múltiple no está soportada en .NET.

¿Cómo podemos hacerle entonces para definir comportamientos que trasciendan las relaciones de identidad? La respuesta es: mediante contratos.

Un contrato básicamente es la promesa de un comportamiento. Cuando uno hace un contrato comercial: quedamos en venderle a la empresa fulana una cantidad equis de refrescos. Pues bien, hacemos un contrato: ahí se estipula que nosotros entregaremos 200 cajas de refresco, cada caja con 10 refrescos cada una, entregables el próximo lunes. Asimismo, establecemos que el cliente nos pagará $10 pesos por refresco, totalizando $20,000, pagaderos contra entrega. Al firmar ambas partes el contrato, garantizamos que vamos a hacer lo ahí estipulado. ¿Cómo lo hagamos? No importa: yo puedo llevar mis refrescos cargando, puedo contratar a algún camión repartidor, etc., mientras que el cliente puede pagarme en efectivo, con cheque, con tarjeta de crédito, etc. El chiste es que se cumpla lo establecido en el contrato.

Pues bien, cuando hablamos de contratos relacionados con clasificaciones, estamos queriendo decir lo mismo: garantizamos que el contrato va a hacer lo que quedamos.

De alguna manera, los comportamientos de una clase constituyen un contrato en sí mismo. En efecto, garantizan un comportamiento gracias a la relación de identidad existente. Pero estos contratos no trascienden dicha relación. Los contratos, en general, siempre trascenderán las relaciones de identidad, y permiten que clases que se adhieran a un contrato específico garantizan el comportamiento, sin importar cómo lo implemente de forma interna.

En C# y .NET los contratos se definen mediante las interfaces.

Interfaces en C# y .NET

Una interfaz es un contrato, el cual define un conjunto de métodos, propiedades y eventos. Una interfaz no define atributos y los métodos y propiedades son solamente declarativos, no definen un cuerpo en particular; asimismo, tampoco pueden tener modificadores de acceso. Simplemente tipo de dato de retorno y parámetros. Las interfaces se declaran mediante la palabra reservada "interface" en C#. La siguiente interfaz muestra cómo declarar una interfaz con un método, una propiedad y un evento. Nota que el nombre de la interfaz comienza con una letra I. Esto es una convención de codificación impuesto por el .NET Framework, pero está tan extendido que nosotros seguiremos esa convención.

interface IAlgunaInterfaz
{
    void Foo();
    string Goo { get; set; }
    event EventHandler Hoo;
}

Cuando una clase se adhiere al contrato de una interfaz, se dice que la implementa. La implementación de una interfaz se hace similar al de la herencia de una clase, con la salvedad que las interfaces siempre van después de la clase heredada (si existiese; separadas por comas). Además, puede implementarse más de una interfaz en una clase determinada (en cuyo caso se separan por una coma). Por otro lado, al implementar una interfaz, la clase en cuestión tiene que definir TODOS los elementos declarados por la interfaz, o si no recibirá un error de compilación.

interface IComparable {
    bool IsEqualTo(object obj);
}

class Shape : IComparable {
    public abstract bool IsEqualTo(object other);
}

class Color : IComparable {
    public bool IsEqualTo(object other) {
        … 
    }
}

Y entonces ahora sí, nuestro algoritmo de búsqueda podría quedar así:

static class Search {
    public static bool Exists(List<IComparable> objects, object objToFind) {
        foreach (IComparable obj in objects) {
            if (obj.IsEqualTo(objToFind))
                return true;
        }
        return false;
    }
}
…

List<IComparable> objs = new List<IComparable>();
objs.Add(new Point(10, 15));
objs.Add(new Line(new Point(42, 42), new Point(25, -14)));
objs.Add(new Color(255, 0, 255));
objs.Add(new Circle(new Point(17, 15), 12));
objs.Add(new Point(42, 42));
objs.Add(new Color(128, 99, 128));

bool exists = Search.Exists(new Point(42, 42));
Console.WriteLine("Punto 42, 42 encontrado: {0}", exists);
exists = Search.Exists(new Color(128, 99, 128));

Console.WriteLine("Color [128, 99, 128] encontrado: {0}", exists);

Por supuesto, siempre podremos referenciar una instancia de una clase por la interfaz que implementa:

IComparable c = new Point(0, 0);

Pero nunca podremos instanciar una interfaz directamente:

IComparable c = new IComparable(); // error de compilación

Ahora bien, hasta este momento hemos visto cómo una interfaz se implementa en una clase con métodos particulares. Pero ¿qué pasa cuando una clase implementa dos interfaces que tienen un mismo nombre? Digo, se supone que los nombres deben ser representativos, pero pues puede ser que dos nombres sean semánticamente diferentes y queramos hacer la diferencia. Este es el caso.

interface IShape {
    void Draw();
}

interface IControl {
    void Draw();
}

class Circle : IShape {
    public void Draw() { … }
}

class Button : IControl {
    public void Draw() { … }
}

class CircledButton : IControl, IShape {
    Circle circle;
    Button button;

    public void Draw() {
        ???
    }
}

En el ejemplo anterior hemos creado una interfaz para definir el funcionamiento de una figura, y el de un control. Definimos un círculo que implementa IShape, y un Button que implementa IControl. Por supuesto, ahora queremos crear un botón circular, por tanto implementamos IControl e IShape. Sin embargo, al implementar Draw: ¿qué versión ha de llamar, la de Circle o la de Button? Debería llamar una u otra, dependiendo de si quien manda llamar está pensando en un IControl o un IShape… Esto es posible mediante la implementación explícita de una interfaz.

Para hacer la implementación explícita, en contraposición de la implícita que ya hemos visto, lo que hacemos es en el método, propiedad o evento, colocar el nombre de la interfaz, seguida de un punto, seguida de… bueno, mejor un ejemplo:

class CircledButton : IControl, IShape {
    Circle circle;
    Button button;

    void IControl.Draw() {
        button.Draw();
    }

    void IShape.Draw() {
        circle.Draw();
    }
}

Como puedes ver, no hay modificadores. Esto es porque un método/propiedad/evento implementado explícitamente NO PUEDE ser invocado desde la clase, sino que tiene que ser invocado desde la interfaz.

CircledButton cb = new CircledButton();
cb.Draw(); // no compila

IControl b = cb;
b.Draw(); // ok, se invoca a IControl.Draw

IShape c = cb;
c.Draw(); // ok, se invoca a IShape.Draw

Por supuesto, si no nos importa y no queremos diferenciarla, podemos dejar la implementación implícita.

class CircledButton : IControl, IShape {
    Circle circle;
    Button button;

    void Draw() {
        circle.Draw();
        button.Draw();
    }
}

Interfaces genéricas

También cabe la pena recordad que las interfaces pueden ser genéricas, aplicando las mismas reglas que para las clases.

interface IComparable<T> {
    bool IsEqualTo(T other);
}

class Shape : IComparable<Shape> {
    public bool IsEqualTo(Shape other) { … }
    ...
}  

class Color : IComparable<Color> {
    public bool IsEqualTo(Color other) { … }
    ...

}  

Ahora bien, las interfaces genéricas son sujetas a un concepto importante de herencia que en .NET se implementó hasta la versión 4.0: la covarianza y contravarianza. Ciertamente esta entrada no entrará a fondo en ambos temas, dado que es uno extenso. Sin embargo, echémosle un vistazo.

Cuando en algún elemento genérico definimos un parámetro genérico T, este puede aceptar tres tipos de parámetros cuando se instancia:

1.- El tipo de dato T tal cual. Esto es lo que hacemos desde .NET 1.0.

2.- Se convierte entre T y tipos más especializados (i.e. clases derivadas).

3.- Se convierte entre T y tipos más generales (i.e. clases base).

Pues bien, al punto 2 se le llama covarianza, y al punto 3, contravarianza. Para declarar un parámetro genérico como covariante, se le añade la palabra reservada out. Para declararlo como contravariante, se le añade la palabra reservada in.

class Clase<T> { … } // ni uno ni otro
class ClaseCV<in T> { … } // contravariante
class ClaseCO<out T> { … } // covariante

// ejemplo de covarianza
List<ClaseCO<object>> co = new List<ClaseCO<object>>();
co.Add(new ClaseCO<object>()); // ok
co.Add(new ClaseCO<string>()); // ok, porque ClaseCO es covariante y string 
                                // deriva de object. 

List<ClaseCO<string>> co = new List<ClaseCO<string>>();
co.Add(new ClaseCO<string>()); // ok
co.Add(new ClaseCO<object>()); // error, porque ClaseCO es covariante y object 
                               // no derivda de string

// ejemplo de contravarianza
List<ClaseCV<object>> cv = new List<ClaseCV<object>>();
cv.Add(new ClaseCV<object>()); // ok
cv.Add(new ClaseCV<string>()); // error, porque ClaseCV es contravariante y 
                               // string es más especializada que object

List<ClaseCV<string>> cv = new List<ClaseCV<string>>();
cv.Add(new ClaseCV<string>()); // ok
cv.Add(new ClaseCV<object>()); // ok, porque ClaseCV es contravariante y object 

                               // es más genérica que string

Más sobre covarianza y contravarianza en este enlace.

Bueno, pues las interfaces pueden declararse como covariantes o contravariantes de la misma forma. De hecho esto puede ayudarnos con nuestro algoritmo de búsqueda. Lo que necesitamos aquí es declarar al parámetro de nuestra interfaz como covariante.

interface IComparable<out T> {
    bool IsEqualTo(T other);
}

class Shape : IComparable<Shape> {
    public bool IsEqualTo(Shape other) { … }
    ...
}  

class Color : IComparable<Color> {
    public bool IsEqualTo(Color other) { … }
    ...
} 

static class Search {
    public static bool Exists(List<IComparable<object>> objects, 
                              object objToFind) 
    {
        foreach (IComparable<object> obj in objects) {
            if (obj.IsEqualTo(objToFind))
                return true;
        }
        return false;
    }
}
…

List<IComparable<object>> objs = new List<IComparable<object>>();
objs.Add(new Point(10, 15)); // a pesar de que implementa IComparable<Shape>
objs.Add(new Line(new Point(42, 42), new Point(25, -14)));
objs.Add(new Color(255, 0, 255)); // a pesar de que implementa IComparable<Color>
objs.Add(new Circle(new Point(17, 15), 12));
objs.Add(new Point(42, 42));
objs.Add(new Color(128, 99, 128));

bool exists = Search.Exists(new Point(42, 42));
Console.WriteLine("Punto 42, 42 encontrado: {0}", exists);
exists = Search.Exists(new Color(128, 99, 128));
Console.WriteLine("Color [128, 99, 128] encontrado: {0}", exists);

Las interfaces de .NET

Ya que hemos platicado de interfaces, como último tema, me gustaría exponer algunas de las interfaces que ya tiene .NET. ¡En efecto! Hay muchas que son muy utilizadas a lo largo de la plataforma, por lo que es importante conocerlas.

La primera sobre la que quiero hablar es la interfaz IDisposable. Esta interfaz representa un objeto que tiene recursos que han de ser liberados en forma determinista, y que no puede esperar a que el colector de basura los libere. Esta interfaz tiene un solo método, llamado Dispose. En este método debe liberarse los recursos asociados.

Supongamos que tenemos una clase, llamada DataLayer, que contiene un elemento SqlConnection, que representa una conexión a una base de datos de SQL Server. Naturalmente queremos que cuando ya no se ocupe DataLayer, se libere la conexión a la DB. Entonces implementamos IDisposable.

class DataLayer : IDisposable
{
    SqlConnection _cnn;
    …

    void Dispose()
    {
        if (_cnn != null) {
            _cnn.Dispose();
            _cnn = null;
        }
    }
}

DataLayer d1 = new DataLayer();
… // usamos el objeto
d1.Dispose(); // liberamos recursos

DataLayer d2 = null;
try {
    d2 = new DataLayer();
    … // usamos el objeto
} catch {
    …
} finally {
    if (d2 != null)
        d2.Dispose();
}

using (DataLayer d3 = new DataLayer())
{
    … // usamos el objeto
} // no necesitamos llamar a Dispose

DataLayer d4 = new DataLayer();
using (d4)
{
    … // usamos el objeto
} // no necesitamos llamar a Dispose

Hemos puesto cuatro ejemplos. El primero muestra cómo invocar Dispose de forma directa, y el segundo, dentro de un bloque try-catch-finally. Los siguientes dos son especiales, pues utilizan la palabra reservada using. Esta palabra acepta (dentro de los paréntesis) un objeto que debe implementar IDisposable. Si no implementa IDisposable, la sentencia causa un error de compilación. La sentencia se encarga de llamar a Dispose cuando se alcanza la llave de cierre del bloque. Si surgiese una excepción, el bloque using se encarga de llamar a Dispose antes de lanzar la excepción, similar al bloque try-catch-finally. Por tanto, no es necesario llamar a Dispose manualmente.

Por cierto, la llamada a Dispose nunca debería lanzar una excepción.

Vamos con la siguiente interfaz. En los ejemplos que mostramos hace rato, creábamos una interfaz llamada IComparable. Pues bien, esta interfaz ya existe y tiene el mismo nombre: IComparable. Esta interfaz tiene un método: CompareTo. Este método debe regresar -1 cuando el objeto actual sea semánticamente menor que el objeto a comparar, 0 si son iguales, y 1 si el otro es mayor. En el caso de variables numéricas, es claro. Pero por ejemplo, una cadena de texto implementa el mayor y menor respecto al orden alfabético de sus letras.

class Point : IComparable {
    public int X { get; set; }
    public int Y { get; set; }

    public int CompareTo(object other)
    {
        Point pt = other as Point;
        if (pt == null)
            throw new ArgumentException("other no es un Point");

        int val;
        if (X < pt.X)
            val = -1;
        else if (X > pt.X)
            val = 1;
        else if (Y < pt.Y)
            val = -1;
        else if (Y > pt.Y)
            val = 1;
        else
            val = 0;

        return val;
    }
}

La interfaz IComparable se utiliza mucho en algoritmos para ordenar colecciones de datos. Por tanto, es importante implementarla en nuestros tipos básicos.

Otra interfaz importante es muy similar a nuestra buena IComparable. Se llama: IEquatable<T>. Esta interfaz tiene un método, Equals(T t), que indica si la instancia actual es igual a alguna otra insatncia. En otras palabras, que Equals regrese true equivaldría a que CompareTo regresara 0.

class Point : IEquatable<Point> 
{
    public int X { get; set; }
    public int Y { get; set; }

    public bool Equals(Point other)
    {
        bool equals = false;
        if (other != null)
        {
            equals = X == other.X && Y == other.Y;
        }

        return equals;
    }
}

Una interfaz clásica también lo es ICloneable. Esta interfaz define un método, Clone, cuya finalidad consiste en crear una copia idéntica del objeto actual.

class Point : ICloneable
{
    public int X { get; set; }
    public int Y { get; set; }

    public Point Clone() {
        return new Point { X = this.X, Y = this.Y };
    }

    object ICloneable.Clone() {
        return new Point { X = this.X, Y = this.Y };
    }
}

Nota que aquí implementamos dos Clones, uno de ellos explícitamente. Esto, porque el explícito regresa un object. El otro, regresa un Point fuertemente tipado. Así cumplimos con la interfaz, pero la clase recibe un valor tipado (Point).

La última interfaz que es súper utilizada es IEnumerable (y su variante genérica IEnumerable<T>). Esta interfaz expone un enumerador. Afortunadamente, ya he hablado de ella en otra entrada, así que hasta aquí la dejamos.

También hay otras interfaces interesantes, que poco a poco iremos explorando. Pero por el momento, creo que son las más importantes. ¡Sigue explorando!

Conclusiones

Esta entrada fue larga. Comenzamos hablando de las clasificaciones del mundo, y poco a poco fuimos introduciendo el tema desde el punto de vista del diseño de software. Pusimos varios ejemplos sobre cómo clasificar, y vimos el concepto de relación de identidad. Una vez dejada clara la teoría, vimos cómo se implementa la clasificación y relación de identidad en C# y .NET, y cómo podemos crear una jerarquía de clases.

Posteriormente, vimos el concepto de comportamiento y contratos, y cómo se implementan en C# mediante el concepto de interfaces. Exploramos varios conceptos relacionados, como interfaces explícitas y covarianza/contrvarianza, y finalmente vimos algunas interfaces comunes en .NET.

Ha sido larguito, espero que haya valido la pena. ¡No dudes en dejar tus preguntas!

Categorías:.NET Framework, Apunte, C# Etiquetas: , , ,
  1. Carlos Osorio M.
    abril 3, 2013 a las 5:26 pm

    Excelente la forma de explicar.

  1. noviembre 27, 2012 a las 7:15 pm

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s