Archive

Posts Tagged ‘Conceptos’

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

Polimorfismo genérico


Si ya tienes tiempo trabajando con programación orientada a objetos, habrás notado cuán útil es el polimorfismo. Un ejemplo muy útil de esto es el patrón de diseño de la estrategia. Pero hay muchos otros ejemplos. Gracias a esto podemos cambiar comportamientos, y bueno, todo lo chido que tenemos con el polimorfismo.

Ahora bien, también tenemos una forma de hacer una especie de polimorfismo genérico, que me gustaría comentar dado que algunas librerías (como ATL o WTL) lo emplean. Consideremos el siguiente ejemplo:

template<class T>
class Base
{
public: 
    void Foo() 
    {
        T* pT = static_cast<T*>(this);
        pT->Imprimir();
    }
 
    void Imprimir() { cout << "Base::Imprimir"; }
};
 
class Derivada1 : public Base<Derivada1>
{
    // no hay nada aquí
};
 
class Derivada2: public Base<Derivada2>
{
    void Imprimir() { cout << "Derivada2::Imprimir"; }
};
 
int main()
{
    Derivada1 d1;
    Derivada2 d2;
 
    d1.Foo();    // imprime: "Base::Imprimir
    d2.Foo();    // imrpime: "Derivada2::Imprimir
 
    return 0;
}

Interesante, ¿no? Quizás hasta desconcertante. La clase Base toma un parámetro de plantilla que, además, espera sea alguna derivada de la misma Base. Lo interesante se presenta en la función Foo. Marcado en negritas, está la siguiente llamada: T* pT = static_cast<T*>(this);. ¿Qué quiere decir? Que vamos a convertir nuestro puntero (this) a una de las clases derivadas. Esto es correcto, toda vez que estamos pensando que el parámetro de plantilla sea una derivada de Base. Entonces, al hacer un static_cast hacia el parámetro de plantilla, e invoca a la función Imprimir de la misma; dado que no es virtual, no se invocará la implementación de la clase derivada, sino la implementación del parámetro de plantilla. Por eso es necesario hacer el static_cast. Así, al convertir a Derivada1, como ésta no tiene una implementación de Imprimir queoculte al Imprimir de Base, entonces se manda llamar a la versión de ésta. Sin embargo, Derivada2 sí implementaImprimir, y por lo tanto, oculta la de Base, y por ende es ésta la versión que es llamada.

Es interesante esta técnica. Tiene algunas ventajas, como el hecho de salvar memoria dado que no hay necesidad de vtables (tablas virtuales), o que todas las llamadas se resuelven en tiempo de compilación, por lo que pueden ser (y son) optimizadas. Pero quizás lo más importante es que es polimorfismo, pero no hay necesidad de tener que declarar una función virtual. Esto es muy importante en diversos escenarios, cuando, digamos, tenemos una librería que implementa el método X y que nosotros quisiéramos sobreescribir: dicho método tendría que haber sido declarado como virtual. Si no lo fué, aún tenemos la posibilidad de emplear el polimorfismo genérico.

Esta es, quizás, la característica más importante de esto.

Categorías:Apunte, C++, Independiente Etiquetas: ,

El problema con try-catch: stack unwinding y problemas de rendimiento

septiembre 27, 2010 3 comentarios

Todos conocemos las ventajas de usar bloques try-catch (o try-catch-finally, para los lenguajes que lo soportan). Pero en muchas ocasiones surgen preguntas sobre el rendimiento que este sistema puede tener sobre nuestra aplicación. Y estas dudas puede que nos hagan recular al momento de encerrar un bloque de código potencialmente peligroso en un bloque try-catch. En otras palabras, ¿los bloques try-catch nos alentan la aplicación? ¿Debo, por ende, colocar estos bloques en todos lados? ¿Qué hago cuando atrapo una excepción?

Hay que comenzar a hacer este análisis desde la base misma: las excepciones. En primera instancia, hay que entender que, a nivel conceptual, las excepciones son eso: casos excepcionales. Situaciones que no deberían presentarse en un escenario normal. Esto, por supuesto, implica que uno tiene que hacer las validaciones en código correspondientes para evitar que las excepciones ocurran. Sin embargo, en algún momento de la vida van a ocurrir: se acabó la memoria de tu máquina, o se cayó la red a la mitad de una transacción de base de datos, etc. Por lo tanto, una solución robusta debe considerarlas como posibilidad. Y en consecuencia, deben ser atrapadas y manejadas convenientemente, de tal suerte que poner bloques try-catch en todos lados donde pueda surgir una excepción parece ser el camino correcto. Pero esto tiene sus matices, como veremos a en un momento.

Esto, sin embargo, puede crear preocupación sobre el hecho de que poner muchos manejadores ocasione una pérdida de rendimiento en la aplicación. Esto, afortunadamente, no es de alarmar. El poner un try-catch no supone problema alguno de sobrecarga ni rendimiento. Se pueden anidar tantos bloques como se requieran, y la pérdida de  memoria y rendimiento sería mínimo, casi nulo. El problema es cuando ocurre una excepción: ahí sí se tiene problemas de rendimiento. Me explico.

En lenguajes estructurados, procedimentales, orientados a objetos y prácticamente cualquiera que no sea ‘espagueti’ se cuenta con funciones que mandan llamar otras funciones. Cuando una función es llamada por otra, el motor de ejecución/máquina virtual/sistema operativo/lo que sea (dependiendo del lenguaje y plataforma) tiene que ubicar memoria suficiente para la función misma (una dirección en memoria), sus parámetros, las variables locales y finalmente, una variable que guarde el valor deretorno (siempre que éste no sea void -o en el caso de VB, que no sea una subrutina en lugar de una función). Al terminar la función, bien porque se llega al final o bien porque alcanza la cláusula return, la memoria asignada se destruye, liberándola. Incluso, en el caso de C++, se llaman a los destructores de aquellas variables locales que hayan sido creadas. Luego entonces, cuando llamas funciones anidadas, tienes que bloques de memoria se van asignado sobre sí: a esto se le llama pila de memoria –el último bloque en ser asignado será el primero en desasignarse. Este diseño hace que el manejo de memoria sea eficiente: no hay que mantener una tabla de referencias de las ubicaciones de memoria ni nada. Simplemente, cuando llega el momento de desasignar, de va por el último bloque de memoria y listo. A todo dar.

El problema es que la definición de excepción, o mejor dicho, de lanzamiento de una excepción, le da al traste a esto. Consideremos estos escenarios (para C++ y C#, respectivamente).

// C++

void boo() {
  try {
     foo();
  } catch (std::exception& e) {
    std::cout << e.what() << endl;
  }
}
 
void foo() {
   goo();
}
 
void goo() {
   hoo();
}
 
void hoo() {
  throw std::logic_error("Hoo!")
}
// C#

void boo() {
  try {
     foo();
  } catch (Exception e) {
    Console.WriteLine(e.Message)
  }
}
 
void foo() {
   goo();
}
 
void goo() {
   hoo();
}
 
void hoo() {
  throw new Exception("Hoo!")
}

Esto es lo que ocurre con este pedazo de código.

  1. Cuando boo se manda llamar, se crea el bloque de memoria para boo.
  2. Boo manda llamar a foo y se crea el bloque de memoria para foo.
  3. Foo manda llamar a goo y se crea el bloque de memoria para goo.
  4. Goo manda llamar a hoo y se crea el bloque de memoria para hoo.
  5. Hoo lanza una excepción, regresa a goo el control en busca de una cláusula catch.
  6. Goo regresa a foo el control en busca de una cláusula catch.
  7. Foo regresa a boo el control en busca de una cláusula catch.
  8. Boo captura la excepción pero no regresa el control, y termina la ejecución de su método en consecuencia.

Como se puede apreciar, el meollo del asunto es que se tiene que revisar la pila de llamadas para ver quién es el afortunado que tiene un bloque catch, y así regresarle el control. Esto equivale a quitar la memoria mientras se busca por el catch, en forma secuencial. Además, como no se conoce qué catch capturará la excepción, no hay mucho espacio para la optimización. A todo este relajo se le conoce como “Stack Unwinding” (ver [1]). Ahora, imaginemos este escenario modificado:

// C++

void boo() {
  try {
    foo();
  } catch (std::exception& e) {
    std::cout << e.what() << endl;
  }
}
 
void foo() {
  try {
    goo();
  } catch (std::exception& e) {
    throw logic_error("Error en goo")
  }
}
 
void goo() {
  try {
    hoo();
  } catch (std::exception& e) {
    throw logic_error("Error en hoo")
  }
}
 
void hoo() {
  throw std::logic_error("A hoo le dio patatús")
}

// C#

void boo() {
  try {
    foo();
  } catch (Exception e) {
    Console.WriteLine(e.Message);
  }
}
 
void foo() {
  try {
    goo();
  } catch (Exception e) {
    throw new Exception("Error en goo", e)
  }
}
 
void goo() {
  try {
    hoo();
  } catch (Exception e) {
    throw new Exception("Error en hoo", e)
  }
}
 
void hoo() {
  throw new Exception("A hoo le dio patatús")
}

En estos ejemplos, podemos apreciar que ocurren tres stack unwindings: hoo para goo, goo para foo, y foo para boo). Se puede ver fácilmente que tener muchos try-catch anidados que lancen excepciones suponen un costo grande de rendimiento, causado por el stack unwinding. Pero esto es así sólo cuando se lanza la excepción y por ende, se provoca el stack unwinding.

Ahora bien, dado que se supone que las excepciones son casos excepcionales, es preferible tener esa pérdida de rendimiento que solo ocurrirá en raras ocasiones, a que el programa truene miserablemente como una patata demasiado cocida. Pero si uno abusa de los try-catch, puede uno terminar con el agua hasta al coronilla: problemas serios de rendimiento.

Para contrarrestar estos efectos sin perder la fortaleza de un try-catch, Microsoft ha recomendado una serie de medidas destinadas a mejorar la práctica del manejo de excepciones (sobre todo, enfocado a su plataforma .NET).

Adicionalmente a éstas, mi modesta experiencia me hace considerar un conjunto de reglas a seguir, sencillas todas, a través de la cual podemos mejorar y robustecer nuestra aplicación. Estas reglas las expongo a continuación.

1. Nunca utilices excepciones para controlar el flujo. Úsalas solo para indicar casos excepcionales que no deberían ocurrir de forma normal. Prefiere hacer validaciones (i.e. prueba si la variable es nula, si el índice del array está dentro de rango, si las precondiciones para un objeto predeterminado se cumplen, etc.) y controla el flujo utilizando if, else, while, return… incluso el goto es preferible.

2. Cuando escribas una propiedad o método, realiza validación de las precondiciones (i. e. que los parámetros no sean nulos, que el estado interno de la clase sea válido, que el objeto no haya sido marcado como "disposable", etc.) y en dado caso que no se cumplan, lanza una excepción adecuada (ArgumentNullException, InvalidOperationException y ObjectDisposedException, respectivamente en C#; o std::invalid_argument, std::logic_error o std::runtime_error respectivamente en C++). Si una de estas excepciones se lanza en algún momento de la vida, tienes un bug (ya que por eso son precondiciones) y tienes que arreglar tu código.

3. Atrapa las excepciones sólo donde lo necesites. Si una función tiene que reaccionar ante una excepción (como cerrar un archivo, abortar una transacción, cancelar una sesión, etc.) entonces usa el catch. Si solo necesitas asegurarte que ciertas tareas se cumplan, no pongas catch y pon nada más el finally (en el caso de C#, C++ no lo soporta). Si a tu función le da igual, en el sentido de que no puede arreglar el desperfecto, no pongas un catch y déjala burbujear hasta que haya una función a la que sí le importe.

4. Coloca manejadores de errores genéricos. Si al final no hubo función alguna que pudiera tratar el error, entonces si no lo controlas tu aplicación tronará miserablemente. Y no hay nada menos profesional que el mensajito de "una excepción no controlada ha sucedido, el programa abortará su ejecución". Si estás con aplicación Windows, pon el try catch en las rutinas de alto nivel (digamos, en los manejadores de los eventos, que son los que suelen iniciar las acciones, o en los manejadores de mensajes, si estás con C++). Si estás con aplicación ASP.NET, asegúrate de contar con una página de errores personalizada y redirigir los errores a ésta a través del web.config (o sobre-escribiendo el Page.OnError). Pon un mensaje de error amigable al usuario, y guarda en algún log la información de la excepción, incluido el stack trace: puede salvarte horas al momento de hacer una depuración.

5. Relanza excepciones solo cuando sea estrictamente necesario. Si un método necesita reaccionar ante una excepción, pero no puede manejarla (i. e. no puede restaurar el estado del sistema por sí misma) pon el catch correspondiente, y después de tu código, relanza la excepción:

// C++
void foo()
{
  try {
    goo();
  } catch (std::exception& e) {
    // hacer algo con excepción
    throw;
  }
}
// C#
void foo()
{
  try {
    goo();
  } catch (Exception e) {
    // hacer algo con excepción
    throw e;
  }
}

En estos casos, causarás otro stack unwinding, pero ni modo, no queda de otra. Pero si no lo necesitas, por favor no lo pongas, o te puedes ver metido en líos de rendimiento, por lo ya explicado.

6. Nunca de los nuncas atrapes una excepción y la dejes sin atender. Es decir, nunca hagas esto:

// C++
void foo() 
{
  try {
    goo(); 
  } catch (std::exception& e) { }
}
// C#
void foo() 
{
  try {
    goo(); 
  } catch (Exception e) { }
}

Cada vez que alguien escribe código como ese un gatito muere. En verdad. Es como si al barrer guardaras el polvo debajo de la alfombra. No te deshaces del problema, solo lo ocultas. Nada más que a veces lo que uno guarda sin darse cuenta puede que no sea polvito, sino el contenido de uno de los canales de aguas negras de Chalco, llenas de pestilencia, enfermedades, gusanos y podredumbre. En serio. Por lo menos guarda el error en el Event Viewer o en un log. Creo que incluso es preferible que truene la aplicación a que no te enteres que hay algo mal.

Bueno, pues espero no haber causado más confusión. Todo lo anterior lo podemos resumir como sigue.

Concuerdo con que muchos try-catch puedan ser señal de falta de validación, pero mientras no lancen excepciones a cada rato y mientras no causen stack unwindings, no deberías tener problemas. Aunque personalmente, pienso que es mejor dejarlas burbujear siempre que sea posible, dado que no hacerlo implica que o bien estás generando muchos stack unwinds (porque si no puedes manejar el error, lo estás relanzando) o bien atrapas la excepción pero no haces nada al respecto (con lo cuál nomás te haces güey metiendo polvo debajo de la alfombra), y ambas acciones son causantes de que recibas un duro y sonoro sape.

Bueno, hasta aquí llega mi perorata. Te sugiero que leas más sobre excepciones, es un tema interesante al que se le puede sacar provecho. Busca en MSDN y en CodeProject, hay muy buenas fuentes.

Sillón leiter.

Categorías:Apunte, C#, C++ Etiquetas: , ,

El misterio de las extensiones de los archivos

septiembre 22, 2010 3 comentarios

Hoy por la mañana un tío en los foros de MSDN hizo una pregunta relacionada con las extensiones de los archivos. En esencia, lo que se preguntaba era que cómo era posible que una aplicación entendiera archivos con extensión totalmente diferente a la original, y que si se podía explicar cómo se codificaban las extensiones de los archivos.

Bien, dado que es un concepto que a veces cuesta trabajo entender (creo que por la naturaleza misma del concepto de extensión) aprovecho para hacer un pequeño apunte al respecto.

Por supuesto, todos conocemos lo que es una extensión de un archivo. Es decir, los documentos de Microsoft Word tienen una extensión .doc, los de Excel .xls, los de texto son .txt y el formato universal para música es .mp3. Pero ¿qué quiere decir esto de extensión? La respuesta no es muy complicada: una extensión no es otra cosa que parte del nombre del archivo. O mejor dicho, forma parte del nombre y son usadas para clasificar a los archivos, de la misma forma en la que nuestros apellidos son nombres para identificar la familia a la que pertenecemos. Pero la extensión no afecta las características de un archivo, así como el nombre no afecta mi ser: yo soy un humano independientemente de si me apellido Gómez, Pérez o Teal’c.

¿Para qué utilizar extensiones entonces? Bueno, el sistema operativo puede hacer búsquedas, filtros, ordenamientos y muchas otras cosas con esta información adicional, de la misma forma en la que una persona en el registro público puede encontrar nuestra información de forma más fácil si le damos nuestro apellido. Pero la extensión de un archivo, repito, no afecta el contenido del mismo.

En esencia, todos los archivos se guardan en un solo formato: binario. Todos, absolutamente todos. Es más, todo lo que está en nuestra máquina se encuentra en binario. En realidad, cuando uno dice que guarda un archivo en formato de texto, de XML, de Word, de PDF, etc., lo que quiere decir es que el arreglo de los bytes está hecho de tal forma que se puede representar como texto, como un XML, o que puede ser interpretado por programas que entiendan el ordenamiento de bytes establecido por Word o Acrobat. Incluso los ejecutables o las librerías de enlace dinámico son binarios, aunque estos son interpretados de forma especial por el sistema operativo.

Luego entonces, la capacidad de un programa de "leer" un tipo de archivo se traduce a si el programa puede o no entender e interpretar el orden de bytes que contiene un determinado archivo. Tomemos como ejemplo al Notepad. La finalidad de este programa consiste en leer y escribir texto simple. La tabla de caracteres ASCII (o UTF, Unicode, etc.) establece una correspondencia entre un byte y un caracter. Por ejemplo, el byte con valor 65 corresponde a la letra latina mayúscula A, mientras que la letra latina minúscula j se corresponde con un byte con valor 106. Y así sucesivamente, para cada caracter. Por ende, el Notepad lo que hace es interpretar estos números y dibujar en pantalla los píxeles correspondientes a cada caracter.

En cambio, un archivo de Word no solamente guarda texto, sino que guarda mucha más información: fuentes, colores, posición de las páginas, encabezados, imágenes, errores ortográficos y un larguísimo etcétera. Si abres un archivo Word con el Notepad verás la interpretación en texto simple del contenido binario. En algunos casos, reconocerás palabras (que se corresponde con el texto que guarda Word, eventualmente), aunque la mayor parte de las veces serán símbolos raros, correspondientes al equivalente en la tabla ASCII de los números que cada byte representa.

En fin, el punto es que la extensión de archivo no hace nada, y de hecho estas son arbitrarias. Normalmente, como programadores escogemos extensiones estándares e intuitivas (como .xml para documentos XML, .cpp y .cs para archivos de código C++ y C#, o incluso .dbf para un archivo que contenga una base de datos), o bien los escogemos por su contenido (por ejemplo, a veces se usa la extensión .dat para indicar un archivo que guarda datos de algún tipo, o .config para indicar que el archivo en cuestión es uno que guarda datos de configuración), o incluso se escogen en base al nombre del programa (por ejemplo, un .psd indica un archivo de imagen de Photoshop, o un .ppt indica un archivo de Power Point). Pero nuevamente, estos son arbitrarios.

Como prueba, hagamos este ejercicio. Abre Microsoft Excel, crea una hoja y mete algunos datos. Luego, guarda el archivo y cierra el programa. Una vez hecho esto, ve y cambia la extensión al archivo, digamos, de prueba.xls a prueba.fer. Abre Excel nuevamente, dale al menú Abrir y navega al directorio donde radica el archivo. Notarás que éste no aparece: esto es así porque Excel filtra los archivos mostrados a aquellos que tengan extensión xls o xlsx (un ejemplo de cómo las extensiones pueden hacer la vida más fácil). En fin, selecciona la opción de “mostrar todos los archivos” y ahora sí, selecciona a prueba.fer. Cuando Excel te pregunte que si deseas abrir el archivo, dile que sí, y voilá: sin problemas mostrará el archivo en cuestión. Esto es así porque solo cambió el nombre del archivo, mas no su contenido. Igual pasa con las extensiones de cualquier otro archivo: al final lo que importa es la interpretación que se le dé al contenido.

Ahora bien, desde el punto de vista de C# y .NET, abrir un archivo cualquiera significa utilizar un objeto de tipo FileInfo y mediante este, obtener un objeto de tipo FileStream (ambos declarados en System.IO). Al FileStream, como a cualquier otro flujo, le podemos leer o escribir bytes (obviamente dependiendo de si abrimos el archivo en modo lectura o escritura). Ahora que podemos usar algunas clases como BinaryReader y BinaryWriter para que nos ayuden a escribir el binario, o bien StreamReader y StreamWriter para que nos ayuden a escribir texto (en este caso, ambas clases se encargan de interpretar el binario como texto). Análogamente tenemos a XmlTextReader y XmlTextWriter. Y así sucesivamente. Bueno, ya en otra ocasión hice una entrada sobre los flujos de datos, ahí hay más información al respecto. Pero el punto es el mismo: lo que se lee y escribe es binario, puros bytes, solo que contamos con algunas clases que nos facilitan la lectura y escritura.

Bueno, pero… ¿qué onda con el formato? Ah bueno. El formato es, digamos, un acuerdo en cómo interpretar los bytes. En el caso de los archivos de texto, la interpretación se hace en base a la tabla ASCII / UTF / Unicode que corresponda. En el caso de un XML, no es otra cosa que un archivo de texto plano que tiene ciertas reglas para crear y acomodar marcas. Por otra parte, un mapa de bits de 32 bits guarda un byte para el color rojo, uno para el verde y uno para el color azul, más otro para la transparencia, aparte de guardar un encabezado con información adicional. Algunos formatos son estándares, como el texto o el XML, algunos son propietarios pero públicos como el JPEG o el GIF, y algunos otros son propietarios y privados, como el PSD o el DOC. De cualquier forma, es necesario conocer el formato de antemano, para leer un archivo. Por ejemplo, en el caso de los mapas de bits, necesitamos leer los primeros 54 bytes del archivo para tener información sobre la profundidad del color, el número de filas, etc.

Por supuesto, tú también puedes crear tus propios formatos. A este proceso se le llama serialización y la mayoría de los lenguajes y plataformas soporta la serialización. En .NET, por ejemplo, basta agregar el atributo [Serializable] a la clase y solito crea el formato necesario para leer y escribir un objeto de ese tipo. Adicionalmente, puedes implementar la interfaz ISerializable y tú mismo controlar qué se serializa y qué no.

En fin, bueno, eso ha sido todo. Espero haber sido claro y no haberte dejado más liado que cuando llegaste. Mi meta es quitar ese como velo de misterio que hay tras las extensiones, y espero haberlo logrado: de esta forma uno puede entender mejor los archivos y como crearlos. Ahí me dices si sí cumplí mi objetivo.

Arrivederci bambini!

Categorías:Apunte Etiquetas: ,