Archive

Posts Tagged ‘OOP’

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: , , ,

Conceptos OOP: encapsulación


Uno de los conceptos importantes con los que contamos en la programación orientada a objetos es el de encapsulación. En esta entrada analizaremos un poco a fondo la teoría detrás de la encapsulación, y cómo podemos implementarla en nuestros lenguajes favoritos.

Comencemos por hacer un análisis que nos lleve a motivar la necesidad de la encapsulación.

Estado de objetos


Recordemos un poco algunas definiciones importantes. Un objeto es una instancia de un tipo de dato (i.e. clase) que contiene atributos (es decir, variables internas). Estos atributos determinan el estado de un objeto. ¿Qué quiere decir un estado? Es la combinación de posibles valores de sus atributos internos.

Por ejemplo, pensemos en el interruptor que nos permite encender y apagar una bombilla incandescente (a.k.a. foco). Si yo hago clic en el interruptor, el foco, que está apagado, se enciende. Si vuelvo a hacer clic, el foco se apaga. En este caso, no podemos hacer nada más que un clic, que enciende y apaga. Ahora bien, el foco sólo puede tener dos estados posibles: o está encendido o está apagado (digo, también puede estar fundido, pero consideremos ese un caso excepcional). Entonces decimos que el conjunto de posibles estados de un sistema foco-interruptor sólo puede tener dos estados posibles, y sólo puede haber dos transiciones posibles: de encendido a apagado, y de apagado a encendido. Además, bajo una regla: si está encendido, se apaga, y si está apagado, se enciende.

Pensemos ahora que queremos representar ese sistema con una clase. Tenemos entonces que crear un atributo (i.e. variable interna) que almacene el estado. Dado que sólo puede haber dos estados posibles, podemos usar una variable de tipo booleano. Este tipo de dato permite justamente dos valores: true o false.

¿Por qué usamos un bool y no un entero, por ejemplo? Podríamos hacerlo, y podríamos decir que un 0 representa "apagado", y un "1" o cualquier otro valor, sea el "encendido". Nos hace falta ahora representar las transiciones. En realidad sólo tenemos una transición: Click, que dependiendo del estado cambia entre encendido o apagado.


El diagrama anterior ilustra los posibles estados del objeto. Vemos que las dos transiciones son en realidad la misma, y que ésta varía dependiendo del estado actual.

Ahora bien, imagínate que un buen día decides cambiar tu sistema por uno de esos focos que permiten tener varios grados de luminosidad. Digamos, el foco puede estar apagado, pero cuando está encendido puede estar en tres modalidades: penumbra, normal y brillante. Entonces tenemos cuatro estados posibles. Pero ¿y cómo se hace la transición? Si el interruptor es una media perilla que recorres de izquierda a derecha, con el izquierdo el estado apagado y el derecho el estado brillante, entonces sólo podemos hacer estas transiciones: apagado a penumbra y viceversa, penumbra a normal y viceversa, normal y brillante y viceversa.

image

 

La cosa se comienza a complicar. Tenemos cuatro estados con seis transiciones. De éstas últimas, se agrupan en tres que aumentan el estado, y tres que lo disminuyen. Al implementarse, probablemente las transiciones sean dos métodos, y los estados una enumeración con las cuatro opciones.

Este interruptor podemos volverlo tan complejo como queramos. Por ejemplo, si el interruptor tiene sensores para detectar comandos de voz, tendríamos seis nuevas transiciones: una para mover la perilla y otra para el comando de voz. Y si pensamos en una perilla entera, en donde el número de grados representa un porcentaje entre la ausencia de luz y el 100% de luminosidad de la bombilla, ya entonces tenemos un estado no dictado por una enumeración, sino por un atributo de tipo entero corto (16 bits en .NET), con un rango que va de 0 a 100 (cada unidad representando el porcentaje deseado). Así, nuestra máquina de estados se vuelve mucho más compleja, pues tiene 101 estados válidos (contando al cero), peeeero ¡65435 estados inválidos! ¿Por qué? Pues porque un entero corto en .NET tiene 16 bits, esto es, puede almacenar 65536 valores (de -32767 a 32768). ¿Notas el problema?

Imaginemos que modelamos los interruptores anteriores como clases o estructuras de C#. Sin contar las transiciones, sino los puros estados, tendríamos algo así.

struct SimpleSwitch {
    bool on;
}

enum DimSwitchState {
    Off, Gloom, Normal, Bright
}


struct DimSwitch {
    DimSwitchState state;
}

struct FullSwitch {
    short brightness;
}

 

Más o menos. Si queremos usar SimpleSwitch, sólo podemos asignarle dos posibles valores, y ambos valores son totalmente válidos. Esto quiere decir que SimpleSwitch nunca puede estar en un estado inválido. Por tanto, podemos estar seguros que esta estructura no nos generará problemas o bugs.

En el caso de DimSwitch, la cosa varía. La enumeración hace un muy buen trabajo al limitar el posible número de estados para la estructura. En este sentido, estableciendo la estructura a Off, Gloom, Normal o Bright tiene el mismo efecto que establecer los valores true o false: el objeto siempre estará en un estado válido. Sin embargo, a diferencia de un bool, las enumeraciones pueden convertirse a partir de valores numéricos. ¡Zaz!

// nada puede malir sal
SimpleSwitch s1;
s1.on = true;
s1.on = false;
s1.on = 42; // ¡no compila!

// hum…
DimSwitch s2;
s2.state = DimSwitchState.Off; // ok
s2.state = DimSwitchState.Gloom; // ok
s2.state = DimSwitchState.Bright; // ok
s2.state = DimSwitchState.Normal; // ok

// so far so good…
s2.state = (DimSwitchState)42; // ¡madres!

 

 

Y valió queso. El idiota programador convirtió un valor numérico cualquiera en la enumeración. Es decir, introduje un valor inválido. Y ahora mi objeto s2 se ha corrompido, y seguramente eso causará errores y bugs más adelante.

El caso anterior es extremo, por supuesto. Ningún programador en su sano juicio haría algo así. Pero sí pueden ocurrir situaciones similares. Por ejemplo, si ese valor se guardara en alguna base de datos, tendría que guardarse en formato numérico. En teoría, nunca se guardaría un valor inválido, pero algún DBA no muy brillante podría actualizar la tabla y meter un 42 por ahí. Entonces ya nos veríamos con este mismo escenario. ¡No muy recomendable!

Hay formas de paliarlo, sin embargo. Por ejemplo, limitando acceso a DBAs que no saben lo que hacen. O bien, podemos hacer uso de el método Enum.IsDefined, al cual le pasamos un valor numérico que nos dice si el valor está definido o no. Algo así.

var value = 42;

if (Enum.IsDefined(value))
    s2.state = (DimSwitchState)value;
else
    throw InvalidOperationException("Enumeración con valor inválido.");

 

 

Y el mundo vuelve a la normalidad. Sin embargo, ya vimos que es necesario siempre realizar un cierto tipo de validación. ¡Esto es dramáticamente cierto para el siguiente ejemplo!

 

FullSwitch s3;
s3.brightness = 50; // ok
s3.brightness += 25; // ok, estamos en 75
s3.brightness += 30; // madres, ya s3 es inválido
s3.brightness = -10; // otra vez, es inválido
s3.brightness = 55;
s3.brightness *= 2; // otra vez inválido



 

Y así podríamos seguirnos. La cantidad de valores inválidos es enorme. Pero más aún, hay muchas operaciones que pueden provocar valores inválidos. Si no tenemos cuidado, esta estructura luce como fuente inagotable de bugs. Por supuesto, hay una forma muy sencilla de evitar caer en estado inválido.

 

var value = -10;
if (value >= 0 && value <= 100)
    s3.brightness = value;
else
    throw new InvalidOperationException("El valor no está en un rango válido.");

 

Y nuevamente hemos paliado la situación, aunque hemos de decir que el riesgo latente siempre estará presente.

Una motivación



 

Estos escenarios que hemos analizado son realmente sencillos. En efecto, una clase con un solo atributo no es nada. Las clases que escribimos en la vida real suelen tener decenas de atributos. Las combinaciones son catastróficas. Una clase con doubles, decimals, strings y longs tienen millones de posibles estados. Y el asunto es combinatorio: si tienes una clase con dos bools, entonces tienes cuatro posibles estados. Y así sucesivamente.

Por supuesto, el asunto no es tan catastrófico. No todos los atributos tienen rangos restringidos. Pero la causa de muchos bugs es que aquellos datos que sí lo están, no se le pone la suficiente atención para prevenir los estados inválidos. Clásico: el nombre de un empleado está limitado a 255 caracteres en una base de datos, y tu pones un string, que puede almacenar mucho más que 255 caracteres. Si no validas, ¡pum! Un SqlException seguro al querer guardar en la DB. O tienes datos que no hacen sentido. En alguna ocasión, en un sistema, vi que podía meter mi edad con un valor negativo. O puedes poner en riesgo la seguridad del sistema. En efecto, cuando actualizamos a base de datos, es de todos sabido que hay ciertos caracteres que no deben utilizase, como la comilla simple, pues puede provocar SQL Injection. Ese es un ejemplo de un estado restringido que no validamos.

El problema de los estados inválidos de objetos es muy grande. Tanto, que han surgido paradigmas y lenguajes que ¡no permiten que los objetos cambien de estado! Es decir, una vez que un objeto se crea, y por tanto, tiene un único estado, ya no puede cambiarse. Puede crearse un nuevo objeto a partir del anterior, pero no cambiarse. Un ejemplo de esto son los lenguajes funcionales, como Ocaml, Haskell o el mismo F# de .NET. Vaya, C# y .NET tienen algunas clases inmutables. La clase String es quizás un ejemplo claro. Una vez que creas el objeto, no hay método que lo modifique. Métodos como Append o Trim devuelven un objeto nuevo. ¿Qué tal?

Pero entonces, ante somera preocupación, surge la duda: ¿quién es el encargado de hacerse responsable del estado del objeto?

No es pregunta sencilla de responder. Cuando todos programábamos en C, allá en los 70s, se decía que el encargado era el método que usaba un objeto. Sin embargo, eso creaba ciertas complicaciones: si José creaba la función F1 y modificaba la estructura S1, y luego llegaba Juan y creaba la función F2, que invocaba a F1 y también cambia el estado de S1, y encima llega Pedro y modifica la F1 creada por José e introduce un estado inválido, ya le dio en la mother tanto a F1 como a F2. Estos escenarios intentaron paliarse con documentación, sin mucho éxito que digamos.

Ante esto, alguien sugirió que el dueño del estado debía ser quien crease el objeto. Pero una vez que éste comienza a ser distribuido por funciones, es imposible que el dueño haga un rastreo a cualquier oportunidad. Entonces, ¿qué puede hacerse?

Estas situaciones propiciaron una respuesta diferente: el objeto mismo debería tener un mecanismo para validar su propio estado, al limitar los rangos de posibles valores que recibe. Cuando existía C, no había forma, pero luego se creó C++, que daba la posibilidad de crear métodos sobre clases. Un método es una función que tiene acceso al estado del objeto al que pertenece. Cuando una función cambia algún valor de alguna variable interna, está cambiando el estado del objeto y se dice que es una transición. De esta forma, comenzó a ser posible llegar a códigos como el siguiente.

 

struct FullSwitch
{
    short brightness;

    void SetBrightness(short value)
    {

        if (value < 0 || value > 100)
            throw new InvalidOperationException("Rango inválido. ");

        brightness = value;
    }
}
...

FullSwitch s;
s.SetBrightness(42); // ok
s.SetBrightness(-10); // oops, ¡excepción!

 

De esta forma nos aseguramos que al invocar SetBrightness no tengamos estados inválidos. Tendremos excepciones, que indicarán bugs durante el desarrollo, pero aseguramos que al menos mientras se invoque dicha función, s será un objeto con estado válido.

Sin embargo, a pesar de lo anterior, nada impide que algún sonso haga esto:

FullSwitch s;
s.SetBrightness(42); // ok
s.brightness = -10; // sigh :(

 

And we’re back to square one. Vamos, que no creo que alguien haga eso voluntariamente. Puede hacerse por desconocimiento, por ejemplo. Clases o estructuras con decenas de atributos, decenas de métodos hacen la vida más difícil. Y luego, si tiene decenas de clases que aprenderte… bueno, digamos que puede ser mucho.

Es por eso que comenzó a surgir el siguiente concepto: ocultar los atributos de una clase. Es decir, que estén disponibles para las transiciones de la clase, pero que noooo estén disponibles para actores externos, a menos que sea necesario. El ocultar, o permitir acceso restringido, a los atributos de una clase comenzó a hacerse más y más necesario, y muy pronto lenguajes como C++ comenzaron a implementarlo. A esto se le llamó encapsulación.

Encapsulación en C#



El concepto de encapsulación, como vemos, es viejo. Hoy día todos los lenguajes orientados a objetos lo implementan de una u otra forma: C++, Java, C#, ¡hasta Visual Basic! Sin embargo, los lenguajes dan la posibilidad de NO implementar la encapsulación. Creo que ya hemos dado argumentado suficiente a favor de este concepto. Definitivamente implementar la encapsulación hará que asegures que una clase tenga puros estados válidos, y esto hará que disminuyan los posibles bugs cuando estés creando modelos de datos, capas de negocio, etc.

En particular, C# implementa varios conceptos de encapsulación, e incluso añade algunos conceptos novedosos no presentes en otros lenguajes. Incluso, la encapsulación aplica a miembros de una clase que no sean atributos, pero que de alguna forma puedan modificar el estado de un objeto. Esto incluye métodos, delegados, eventos, etc.

Veamos.

Modificadores de acceso



Comenzamos por los modificadores de acceso. En la sección anterior, veíamos este ejemplo.

FullSwitch s;
s.SetBrightness(42); // ok
s.brightness = -10; // sigh :(



Aquí, vemos que a pesar de tener nuestro método SetBrightness, podemos cambiar el atributo asignándole un valor directamente. Entonces, lo que quisiéramos es que el atributo estuviera disponible para los métodos de la clase, pero no para los externos. En C#, esto es posible con los modificadores de acceso, que tienen los siguientes niveles de acceso.

1.- Acceso privado. Sólo los métodos que estén dentro de la propia clase pueden acceder a estos atributos. Ni siquiera las clases anidadas o clases derivadas pueden hacerlo. Se identifica mediante la palabra reservada "private".

struct FullSwitch {
    private int brightness;

    int Get() { return brightness; } // ok

    void Set(int value) {

        if (value < 0 && value > 100) 
            throw new ArgumentException("Rango inválido.");

        brightness = value; // ok
    }
}

...

FullSwitch s;
s.Set(42); // ok
int i = s.Get(); // ok, i == 42
s.brightness = -10; // ¡no compila!
s.Set(-10); // ok, pero lanza excepción: ¡s nunca queda en estado inválido!

 

2.- Acceso público. Cualquier elemento fuera de la clase en cualquier ubicación podrá ver el atributo, clase, método, delegado, evento, etc. Es el nivel más alto de acceso, y por tanto sólo debe exponerse para miembros que no vayan a dejar en estado inválido nuestra clase. Se identifica mediante la palabra reservada "public".

Por supuesto, nuca querremos declarar públicos nuestros atributos. Esto violaría el principio de encapsulación, y nos daría en la torre por todo lo ya expuesto.

Hay algunos casos, sin embargo, donde se considera válido que un atributo sea público. Esto es para aquellos atributos que no cambian: estáticos de sólo lectura o constantes. Se considera admisible porque al ser constantes o de solo lectura, su estado es siempre inmutable, y por tanto no afectan el estado general del objeto. Algunos ejemplos que implementa el propio .NET Framework.

 

  • String.Empty. Representa una cadena de texto vacía. Es equivalente a poner un par de comillas: "".
  • Int32.MinValue e Int32.MaxValue. Representan los valores mínimos y máximos de un número entero.
  • Uri.UriSchemeHttp. Representa el esquema de un enlace de HTTP. Es decir, la cadena "http://".
  • DBNull.Value. Representa el valor nulo, no asignado, que puede darse a un campo en base de datos.

Como puedes ver, todas esas son inofensivas, desde el punto de vista del estado de sus objetos, y por tanto son aceptables aunque rompan el principio de encapsulación.

3.- Acceso protegido. Los elementos así declarados, mediante la palabra reservada "protected", no están accesibles fuera de la clase, igual que con "private". Pero sí están accesibles dentro de la clase, y además, están accesibles para elementos derivados. Un ejemplo:

class FullSwitch {    
    protected int brightness;

    public int Get() { ... }
    public void Set(int value) { … }
}


class PrintFullSwitch : FullSwitch
{
    public void Print() {
        Console.WriteLine(brightness); // ok!
    }
}

PrintFullSwitch s;
s.Print(); // ok!
s.brightness = 42; // no compila

 

En este ejemplo hemos declarado a brightness como protegida, así que la clase derivada PrintFullSwitch puede acceder a ella, como se vio en el ejemplo.

Hay que tener cuidado, sin embargo, con todo lo protegido. Esto, porque abre la posibilidad de que clases derivadas cambien el estado del objeto. Y en general abrimos la puerta a que alguien derive y comience a causar estragos, si no sabe lo que hace. Por eso, en general, vamos a preferir declarar nuestros atributos como privados, y métodos y demás objetos que acceden a ellos, como protegidos o públicos.

class Switch {
    private int brightness;

    public int Get() { ... }

    protected void Set(int value) { 
        if (value < 0 || value > 100)
            throw new ArgumentException("Rango inválido.");
    }
}


...
   
class FullSwitch {
    public void ChangeValue(int value) {
        Set(value); // ok, si value es inválido, Set se 
        // encarga de lanzar la excepción. 
        ...
    }

    public void ScrewState() {
        Set(-10); // aunque queramos echar a perder el estado,
                      // Set lo impide siempre. 
    }
}

 

En general, deberás tener muy en cuenta hasta qué punto quieres que clases derivadas modifiquen el estado de tu objeto.

4.- Acceso interno. Quiere decir que un miembro es público para el ensamblado que lo contiene, pero es privado fuera de ese ensamblado. Se identifica por la palabra reservada "internal".

Piensa este escenario. Tienes un equipo de trabajo, y entre todos están construyendo un ensamblado para un proyecto. Ese ensamblado contienen clases que pueden ser usadas algún día por algún tercero de otra empresa. En esta situación, te das cuenta que tienes ciertos métodos o atributos que son peligrosos para la integridad del estado de una clase determinada, pero sabes que tus colegas no van a cometer errores porque están entrenados, conocen el proyecto, etc. Sin embargo, te da miedo que el tercero llegue y cometa errores. ¿Qué hacer?

Justo para esto se inventó el "internal". Si declaras como internal un miembro cualquiera, éste estará disponible como si fuera público para cualquier otro objeto dentro del mismo ensamblado (i.e. programa ejecutable o DLL), y sin embargo será privado para cualquier otro ensamblado. Ahora bien, quien puede crear objetos desde el mismo ensamblado es quien tiene el código fuente: tú o tu equipo de trabajo. ¡Entonces un internal resuelve el problema!

 

// switch.cs dentro de Switch.dll
class Switch {
    public void Click() { … }

    internal int brightness;
    ...
}


// foo.cs dentro de Switch.dll
Switch s = new Switch(); {
    s.Click(); // ok
    s.brightness = 42; // ok

// goo.cs dentro de ThirdParty.dll referenciando a Switch.dll
Switch s = new Switch();
s.Click(); // ok porque es público

s.brightness = 42; // ¡error! para este ensamblado es privado.

 

5.- Acceso interno protegido. Un caso muy similar al anterior. Quiere decir que un miembro es protegido para el ensamblado que lo contiene, pero es privado fuera de ese ensamblado. Se identifica con las palabras "protected internal".

Si no quieres hacer "internal" un atributo, por ejemplo, pero sí te gustaría que estuviera protegido para clases derivadas, pero privado para clases que deriven en otros ensamblados, entonces usa protected intenal.

 

// switch.cs dentro de Switch.dll
class Switch {
    protected internal int brightness;
    ...
}


// foo.cs dentro de Switch.dll
class FullSwitch : Switch {
    void Click() { 
    brightness = 42; // ok
}


// goo.cs dentro de ThirdParty.dll referenciando a Switch.dll
class OtherSwith : Switch {
void OtherClick() { 
brightness = 42; // ¡no compila! para este ensamblado es privado
}


}


 

Bueno, con eso cubrimos los cinco modificadores de acceso. No dejes de utilizarlos, puesto que representan la mitad del encapsulamiento existente en C#. La otra mitad corresponde a los accesos.

Accediendo los atributos



Bueno, ya hemos visto que con los modificadores ocultamos los atributos y miembros y sólo exponemos aquellos que sabemos no pueden ser utilizados de mala forma por agentes externos. Ahora queda la pregunta: ¿cómo exponemos los atributos, parcial o totalmente, a los externos? Lo primero que nos viene a la mente son los métodos. Ya hemos visto algunos de ellos, pero pongamos uno que sirva de base para posteriores ejemplos.

class Switch
{
    private bool _turnOn;

    public bool GetTurnOn() { return _turnOn; }

    public void SetTurnOn(bool value) { _turnOn = value; }
}

 

Este es un ejemplo básico. Tenemos un par de funciones que obtienen y establecen el valor del atributo. Estamos respetando las reglas de encapsulamiento a la perfección. De hecho esta forma es muy popular en lenguajes como C++ y Java, tanto que este tipo de métodos tienen sus nombres: métodos "getter" y métodos "setter", precisamente porque siempre se llaman igual que el atributo (TurnOn) pero con el "Get" y "Set" como prefijos.

Utilizar métodos getters y setters no tiene nada de malo, de hecho. Sin embargo, tienen un inconveniente que es más semántico que otra cosa. Un método, por su naturaleza, tiene en potencia la capacidad de alterar el estado del objeto. Así, para un agente externo, invocar un Get o un Set no garantiza que se respete el estado del objeto. En C++ existe una construcción que permiten declarar métodos constantes. Un método constante no puede cambiar atibutos (a menos que el atributo se le declare como variable "mutable"). Ejemplo:

 

class switch() {
    private:
        bool _turnOn;

    public:
        bool get() const { 
            return _turnOn;
        }

        void set(bool value) {
            _turnOn = value;
        }

        void click() const { 
            _turnOn = !_turnOn; // oh-oh!
        }
};



 

 

En la clase anterior de C++, nota que los métodos get y click tienen la palabra "const" después de los paréntesis. Esto quiere decir que esos métodos no pueden cambiar sus atributos. En el caso de get, no pasa nada porque nada más se hace el return. Sin embargo, el método click provoca un error de compilación, puesto que el método es constante y se intenta cambiar un miembro: el atributo _turnOn. En general, se conoce como "const-correctness" esta técnica, y es muy útil para asegurar métodos que no cambien el estado de un objeto.

En C#, este concepto no existe. Sin embargo, se creó algo para ayudar un poco a acercarnos a esta construcción: las propiedades.

Una propiedad en C# se "parece" mucho a un atributo, con la diferencia que las propiedades no generan un espacio en memoria. En contraposición, las propiedades cuentan con un cuerpo, el cual puede tener dos "accesores": un get y un set (o puede tener sólo uno). Cada uno de éstos tiene su propio cuerpo, y en éste podemos poner tareas normales que pondrías en cualquier método. El único requisito es que para los get, debe haber un return, y para los set, uno puede usar la palabra reservada "value" para referir al valor que se está estableciendo.

 

class Switch {
    private bool _turnOn;


    public bool TurnOn {
        get {
            return _turnOn;
        }
        set {
            _turnOn = value;
        }
    }
}


Switch s = new Switch();
s.TurnOn = true;
var val = s.TurnOn;

 

Como puedes ver, es muy similar a tener un atributo público llamado "TurnOn". Sin embargo, ¡siempre puedes realizar validaciones en el set, o puedes computar valores en el get!

 

class Switch {
    private int _brightness;

    public int Brightness{
        get {
            return _brightness;
        }
        set {
            if (value < 0 || value > 100)
                throw new ArgumentException("Rango inválido. ");

            _brightness = value;
        }
    }
    
    public bool IsOn {
        get {
            return _brightness != 0
        }
    }

Switch s = new Switch();
s.Brightness = 42;
var val = s.IsOn; // val es true        
val = s.IsOn; // val es false


 

Ahora, como puedes ver no hay mucha diferencia entre usar propiedades y usar métodos. Digo, la sintaxis sí es diferente, pero ¿qué ventajas tenemos de usar propiedades? La verdad es que la ventaja es por convención. Si un getter puede cambiar el estado de un objeto, o bien tiene que realizar computaciones muy grandes que pueden consumir significativamente memoria o procesamiento, usamos un método de la forma "GetPropiedad". En caso contrario, usamos una propiedad "Propiedad { get; }". Por otro lado, todos los setters cambian el estado del objeto (esa es su función). La regla es: si un setter cambia el estado del objeto en más de un atributo, o bien se realiza una tarea de procesamiento intensivo, utilizamos un método de la forma "SetPropiedad". En caso contrario, usamos una propiedad "Propiedad { set; }".

 

class Employee {
        private string _name;
        
        private Dictionary<DateTime, double> _salaryHistory;
        public event EventHandler NameChanged;

    public string Name{
        get {    return _name; }

    set {
        if (value == null)
            throw new ArgumentNullException("value");

        if (_name != value) {
            _name = value;    
    }

    if (NameChanged != null)
        NameChanged(this, EventArgs.Empty);
    }
}


}


public IDictionary<DateTime, double> SalaryHistory {
    get {
        if (_salaryHistory == null)
            _salaryHistory = new Dictionary<DateTime, double>();

        return _salaryHistory;
    }
}


public double GetAverage() {
    double avg = 0.0;

    foreach (var item in _salaryHistory)
        avg += item.Value;

    return avg;
}

public void SetAverage(double avg) {
    foreach (var item in _salaryHistory)
        item.Value = avg;
}

    public bool IsOn {

    get {
        return _brightness != 0;
        }
    }
}

 

El código anterior muestra varios aspectos interesantes. La clase muestra una propiedad Name, cuyo getter simplemente regresa una variable local de la clase. El setter, sin embargo, hace cosas más interesantes. Primero, verifica que el valor asignado no sea nulo (si lo es, lanza un ArgumentNullException). Luego, revisa si el valor del atributo es diferente al valor asignado, en cuyo caso aparte de actualizar el atributo con el nuevo valor, dispara un evento, llamado NameChanged. Este uso de una propiedad para validar entrada de valores y para lanzar notificaciones es sumamente común.

Luego, tenemos una propiedad llamada SalaryHistory. Esta propiedad tiene como tipo de dato una interfaz IDictionary. Sin embargo, vemos que internamente todo se guarda en un objeto de tipo Dictionary, el cual por supuesto implementa IDictionary. Este es un uso común de las propiedades: no queremos que nuestro cliente sepa qué objeto interno utilizamos: basta con que se sepa que es un IDictionary. Otro ejemplo sería regresar un IEnumerable, sin importar si internamente se implementa como un Array, un List<T> o on Collection<T>.

Adicional a esto, tenemos que el getter revisa si el atributo es nulo. En caso de serlo, inicializa el objeto antes de regresarlo. Esta técnica, llamada "lazy initialization" o "inicialización retardada", es útil cuando tenemos objetos miembro que no necesariamente ocupamos desde que se instancia el objeto padre. Por último, nota que la propiedad no tiene setter, puesto que no es necesario cambiar el objeto diccionario, sino su contenido.

Posteriormente, tenemos un par de métodos GetAverage y SetAverage. Estos son métodos porque hacen cómputo (relativamente) intenso. Podríamos poner una propiedad, pero de esta forma le indicamos al cliente de la clase que se espera no esté invocando los métodos a cada rato. Este sería un mal uso:

 

Employee e = new Employee();
...
double[] vals { … };
foreach (double val in vals)
{
    if (val > e.Average)
        // hacer algo
}

Para cada iteración, hacemos un cálculo para ver si el valor es mayor al promedio provisto por la hipotética e.Average. Durante esta iteración, e.Average será siempre la misma. Sin embargo, ejecutamos el cálculo una y otra vez, lo cual no es muy eficiente. Sin embargo, como Average es una propiedad, no me da indicios de que se estén produciendo cálculos. Ahora considera esto:

Employee e = new Employee();
...
double[] vals { … };
foreach (double val in vals)
{
    if (val > e.GetAverage())
        // hacer algo
}

Así al menos estamos conscientes de que se ejecuta un método, el cual hace cálculos. Al revisar esto, veríamos que estamos haciendo algo no tan eficiente, y probablemente decidamos cambiar nuestro código a:

Employee e = new Employee();
...
double avg = e.GetAverage();

double[] vals { … };
foreach (double val in vals)
{
    if (val > avg)
        // hacer algo
}

Mucho mejor, ¿no?

Bueno, ya regresando al ejemplo anterior, tenemos la propiedad IsOn, la cual regresa un resultado basado en el cómputo de otro tipo de atributos. Este es otro ejemplo de uso común de las propiedades.

Y ya por último, tenemos una declaración rara de una propiedad, llamada Address. Esta declaración de propiedad define un getter y un setter, pero no define el cuerpo. Cuando declaramos una propiedad así, estamos declarando una propiedad abreviada, y el compilador lo que hace es generar un atributo privado y enlazarlo al cuerpo get y set de la propiedad. Es decir, al escribir:

public string Address { get; set; }

el compilador lo transforma en:

private _Address;
public string Address { 
    get { return _Address; }
    set { _Address = value; }
}

Solo que el compilador lo hace por nosotros. Es una forma de azúcar sintáctico para que escribamos menos .

Ahora bien, las propiedades tienen un modificador de acceso, por lo que también pueden ser públicos, privados, protegidos, internos e internos protegidos. Declararlos como tales provoca que tanto getter como setter tengan el mismo nivel de acceso. Sin embargo, ¿qué pasa si queremos que uno de los elementos tenga un nivel de acceso diferente? Por ejemplo, si tenemos una propiedad pública, pero queremos que el setter sea protegida, ¿cómo lo hacemos?

Lo que se hace en este caso es calificar el getter o setter en cuestión con el modificador deseado, siempre y cuando el nivel de acceso de éste sea menor que el de la propiedad:

public string Address { 
    get { return _Address; }
    protected set { _Address = value; }
}
…
Employee e = new Employee();
var a = e.Address; // ok
e.Address = "dirección"; // ¡error de compilación! sólo clases que hereden
                              // de Employee pueden modificar esa propiedad.

El tema importante aquí es el nivel de acceso. El nivel público tiene mayor nivel de acceso que protected, y éste tiene mayor nivel de acceso que privado, y así sucesivamente. Así, el siguiente código no compila:

protected string Address { 
    get { return _Address; }
    public set { _Address = value; }
}

Creo que ha quedado claro el uso de las propiedades como medida para garantizar un buen encapsulamiento. Ciertamente es mejor que exponer los atributos directamente. En .NET, aún cuando un atributo no requiera más que existir (i.e. no necesite procesamiento en el get o en el set adicional), es buena práctica el tener las propiedades.

Implementación explícita de interfaces

En C# y .NET, las interfaces son un mecanismo de herencia muy difundido en toda la plataforma. Las interfaces, sin embargo, también nos ayudan a mejorar el encapsulamiento, principalmente a través de la implementación explícita de interfaces.

Este tipo de implementación se hace prefijando el nombre de la propia interfaz seguida de un punto, seguida del nombre del método o la propiedad. Tras lo discutido en la sección anterior, nos interesa en particular las propiedades, que nos ayudan a encapsular los atributos. Esta implementación explícita siempre será pública, y no puede ser declarada como abstracta, virtual, sobrescrita u oculta (abstract, virtual, override, new). Y lo que es importante: no puede ser accedida directamente. Vamos por partes.

interface IContactable
{
    string Address { get; set; }
    string Phone { get; set; }
}

public Employe : IContactable
{
    public string Address { get; set; } // implementación implícita
    string IContactable.Phone { get; set; } // implementación explícita
}

Employee e = new Employee();
e.Address = "Dirección"; // ok, no problemo
e.Phone = "55156789"; // ¡no compila! no puede ser accedida directamente

IContactable c = e as IContactable; // convertimos
c.Phone = "55156789"; // ok, ¡ahora sí funciona!

Es muy importante hacer notar que Phone no puede ser accedido mediante la clase Employee, pero si convertimos a la interfaz, IContactable, entonces sí podemos acceder a dicha propiedad. Esto es importante, pues nos hace notar cómo la implementación explícita de interfaces puede ayudarnos a mantener el encapsulamiento, al permitirnos ocultar propiedades que no queramos que estén disponibles directamente.

…y otra cosa

Bueno, creo que hemos hablado mucho sobre el tema del encapsulamiento. Resumiendo, hemos comenzado por definir el estado de un objeto y sus implicaciones dentro de la programación orientada a objetos. Luego, intentamos motivar, a partir de ello, la necesidad del encapsulamiento de los atributos de nuestros objetos. Por último, vimos cómo C# y .NET implementan dicho encapsulamiento, mediante los modificadores de acceso, los métodos y las propiedades, y la implementación explícita de interfaces.

No queda más, pues, que hacer la recomendación a título personal: por favor, siempre asegúrate que tus atributos estén encapsulados. Nunca sabes cuándo otro programador va a meterle mano a tu código. Encapsular el estado de tu objeto puede ser vital para que el otro no la riegue. De igual forma, cuando tú metas mano en código ajeno y veas un atributo público, desconfía de él: busca dónde se utiliza y bajo que circunstancias, no sea que le des en la torre al estado del objeto.

Pues eso es todo. ¡Hasta la próxima!

Categorías:.NET Framework, Apunte, C# Etiquetas: ,