|
Píldoras Orientadas a Objetos
Las propiedades o métodos que devuelven vectores son una mala idea
|
? Suponga que tenemos una clase diseñada de esta manera:
public class Container
{
public ItemType[] GetItems()
{
// ...
}
}
En ese caso, es muy probable que GetItems esté ofreciendo más información y más control que:
- Los que realmente necesita el usuario de la clase.
- Los que usted está realmente preparado para garantizar como "seguros" de usar.
Plantéeselo del siguiente modo: ¿qué quiero realmente que pueda hacer el usuario con ese vector? ¿Recorrer sus elementos linealmente, de arriba a abajo? Entonces cambie la definición de esta manera:
public class Container
{
public IEnumerable<ItemType> GetItems()
{
// ...
}
}
"Ah", dirá alguno, "no lo he hecho así para exprimir la eficiencia". Bien, reconozcamos que es más eficiente recorrer los elementos de un vector que los de una interfaz enumerable genérica. Pero entonces se nos presenta otro problema. ¿Dónde residen los elementos que ofrecemos agrupados en un vector? ¿En un vector interno cuya referencia entregamos al usuario de la clase? ¿Se da cuenta de que el usuario puede destrozar dicho vector con suma facilidad? En estos casos, el programador que se aferra al vector termina creando una copia del vector para evitar destrozos... ¡pero eso elimina por completo la ventaja en eficiencia!
Incluso cuando los elementos originales y privados residen en una lista genérica, y cuando queremos que el usuario pueda añadir o eliminar elementos de dicha lista, es preferible hacer que el método o propiedad devuelvan una de las interfaces genéricas como IList<X> o ICollection<X>. Tenga presente también que los vectores en el CLR implementan automáticamente tres interfaces genéricas: IList<X>, ICollection<X> e IEnumerable<X>. Pero no olvide que los métodos de la interfaz ICollection<X> que añaden o quitan elementos provocan una excepción si son ejecutados.
Conviene también saber que la clase genérica List<X> ofrece un método AsReadOnly que encapsula la lista dentro de una colección de sólo lectura. Cada vez que se ejecuta AsReadOnly se crea una instancia de la clase ReadOnlyCollection<X>, pero si necesitamos publicar una lista como una colección de sólo lectura, siempre podemos ejecutar AsReadOnly una sola vez, y memorizar la referencia obtenida dentro de un campo de la clase contenedora.
|
Los métodos de extensión son ligeramente más eficientes que los métodos de instancia.
|
¸ No he querido darle forma de regla a este consejo, porque es bastante peligroso intentar sacarle partido... pero es información, y usted tiene derecho a ella.
Los métodos de instancia en C# casi siempre son ejecutados mediante el código de operación callvirt, de IL. Este código, al traducirse, genera una comprobación del puntero del objeto activo: si éste es nulo, se produce una excepción. Esto es lógico: una llamada a un método virtual debe buscar la dirección del método concreto en la Tabla de Métodos Virtuales, y si el objeto activo es nulo, no hay Tabla de Métodos Virtuales. Lo sorprendente, sin embargo, es que se use este mismo código IL para llamar a métodos que no son virtuales. La explicación está en la especificación formal de C#: la verificación de no nulidad debe ejecutarse, sin importar si el método es virtual o no.
En cambio, las llamadas a métodos de extensión se traducen en simples llamadas a métodos estáticos... que no están sujetos a esta regla. Claro, hay peligros en intentar aprovechar este detalle:
- El método de extensión debe operar con el estado público de la instancia. Esto se podría haber solucionado permitiendo métodos de extensión en clases anidadas... pero esto no es posible, al menos de momento.
- Si olvida incluir el espacio de nombres de la clase de extensión en una cláusula using, el compilador no se enterará de la existencia del método de extensión.
- Ya más en general, el uso de métodos de extensión con clases plantea riesgos con el versionado. Este problema también se presenta con los tipos de interfaz, pero estos últimos, en general, son más estables respecto a los cambios de versión.
Por supuesto, si está dispuesto a renunciar a la sintaxis de llamada de métodos de instancia, puede implementar el método deseado como un método estático común y corriente dentro de la propia clase. Le toca a usted decidir si la pequeñísima ganancia en velocidad merece o no la pena.
|
Utilice métodos de extensión como valor añadido al definir interfaces.
|
? Muchas veces, al definir un tipo de interfaz, descubrimos que algunos de los métodos que deseamos se podrían implementar en base a otros métodos y propiedades de la interfaz. Por ejemplo:
- En una pila, el método Clear puede implementarse mediante un bucle, la propiedad IsEmpty y el bueno y viejo Pop.
- En la misma pila, es a veces recomendable tener un método Push adicional, que reciba un vector o una colección de valores para añadir en una sola operación. Es el patrón, bastante frecuente en .NET, que se produce con Add y AddRange en clases e interfaces relacionadas con colecciones.
Sería un error de diseño incluir estos métodos en la definición de la interfaz, porque obligaríamos al implementador de las mismas a proporcionar una implementación al método, tantas veces como implementaciones cree.
Hasta C# 2.0, la única solución era crear una clase auxiliar que proporcionase las implementaciones deseadas, y proporcionarla como parte del paquete... pero era una solución fea: el implementador tenía que basarse en la documentación para descubrir la existencia de las clases auxiliares. Con C# 3.0 y sus métodos de extensión, la cosa cambia, pues podemos hacer que la clase auxiliar sea una clase estática, y que los métodos adicionales se definan como métodos de extensión para el tipo de interfaz:
public static class IStackExtensions
{
public static void Push<X>(this IStack<X> stack, params X[] values)
{
foreach (X value in values)
stack.Push(X);
}
public static void Clear<X>(this IStack<X> stack)
{
while (!stack.IsEmpty)
stack.Pop();
}
}
Observe que, por tratarse de una interfaz genérica, los métodos de extensión deben ser también genéricos, aunque esto no complica para nada el uso de los mismos, gracias a la inferencia de tipos:
StackImpl<int> pila = new StackImpl<int>();
pila.Push(1, 2, 3, 4, 5);
// ...
pila.Clear();
Aunque definimos los métodos de extensión para la interfaz, el polimorfismo nos permite usarlos con variables declaradas con el tipo de cualquiera de las clases que implementan la interfaz. La inferencia de tipos, como ya dije, nos evita aclarar explícitamente el parámetro de tipo en los métodos genéricos. Como curiosidad, cuando pase un solo parámetro a Push, el mecanismo de resolución de sobrecargas de C# detectará correctamente que deseamos llamar a la versión definida en la interfaz, no al método de extensión.
Existe un peligro, de todos modos: debe incluir el espacio de nombres de la interfaz y la clase auxiliar en una cláusula using para que el compilador detecte los métodos de extensión. Por supuesto, si está trabajando en el mismo espacio de nombres, esto es innecesario.
|
Redefina Equals y el operador == cuando defina estructuras.
|
¸ Cómo ve, hay un buen montón de reglas relacionadas con el método Equals. En este caso,
estamos hablando de estructuras, y se trata de un consejo relacionado con la eficiencia.
Todas las estructuras derivan de la clase ValueType. Esta clase reimplementa tanto Equals como GetHashCode de manera que se utilizan los campos de la instancia. El problema: esta implementación heredable se basa en la reflexión... y es lenta. Es aconsejable,
por lo tanto, sustituir ambas para ganar velocidad en las comparaciones. Una vez redefinido Equals, debemos también redefinir GetHashCode.
Como estamos tratando con un tipo de valor, debemos también redefinir el operador == para que su definición coincida con la de Equals. En un tipo de referencia, el operador == debe funcionar comparando referencias, excepto en el caso de tipos inmutables... pero estamos hablando de estructuras. Al redefinir ==, las reglas del lenguaje nos obligan a redefinir también la desigualdad, es decir, el operador !=. En total, cuando se define una estructura, si queremos jugar limpio, tendremos que redefinir dos métodos virtuales y dos operadores.
|
Si redefine Equals, debe redefinir GetHashCode, y viceversa.
|
& Más información de manual, relacionada también con el método Equals introducido en la clase System.Object.
La explicación de esta regla es muy sencilla: si dos instancias de una clase se consideran "iguales", están obligadas a devolver la misma clave de hash. Considere la siguiente clase:
public class Rational
{
private int num, den;
// ...
public override bool Equals(object other)
{
Rational r = other as Rational;
if (r == null)
return false;
else
return Math.BigMul(num, r.den) == Math.BigMul(den, r.num);
}
}
Rational es una clase, es decir, un tipo de referencia, por lo que la implementación predefinida de GetHashCode devolvería valores diferentes para instancias con el mismo numerador y denominador (¡compruébelo!). El problema se agrava, además, porque dos fracciones pueden ser iguales aunque se representen de manera diferente (suponiendo que no están siempre normalizadas). La clase define Equals para que compare el estado abstracto de las instancias. Observe el uso del método BigMul de la clase Math, que devuelve un tipo long: esta llamada nos permite evitar problemas de desbordamiento.
Si no implementamos GetHashCode, podríamos tener dos instancias "iguales" con distintas claves de hash... ¡incluso olvidando el problema de la normalización! La implementación de GetHashCode tiene truco:
public class Rational
{
// ...
public override int GetHashCode()
{
double d = ((double)num) / den;
return d.GetHashCode();
}
}
Confieso que la "solución" anterior no me agrada mucho: podemos tener problemas de precisión en casos extremos. Pero en general, es aceptable. Naturalmente, si garantizáramos que las instancias de Rational siempre estarán normalizadas, podríamos tener una implementación más segura y probablemente más eficiente:
public class Rational
{
// ...
// ¡Sólo si no hay factor común!
public override int GetHashCode()
{
return num.GetHashCode() ^ den.GetHashCode();
}
}
El operador ^ es el xor binario, utilizado para combinar claves de hash por destruir menos información que las alternativas usuales. Por supuesto, podríamos también realizar la normalización del racional en la implementación de GetHashCode, pero es necesario que este método sea lo más eficiente posible. Tendríamos entonces que mantener un campo adicional para indicarnos si la fracción ya ha sido normalizada antes.
Nota: ¿Se ha dado cuenta ya de por qué Rational no debe ser una estructura?
|
Algunos usos del modificador friend de C++ pueden lograrse en C# anidando clases.
|
& Esto es información de manual, pero es también una de las características menos conocidas de C#, en mi opinión.
El modificador de acceso friend de C++ permite conceder acceso a los recursos privados de una clase, a las clases que consideremos que necesitan este acceso. C# ofrece los niveles internal y protected internal para este propósito. No obstante, es un mecanismo nada selectivo: cuando se abren las puertas de una clase de esta manera, todas las clases ubicadas en el mismo ensamblado físico pueden hacer de las suyas con los recursos desprotegidos. Es cierto que, en la práctica, el mecanismo funciona, sobre todo porque se trata casi siempre de clases programadas por uno mismo... pero recuerde: encapsular no es esconder secretos de la implementación, sino minimizar las dependencias entre creador y consumidor.
No obstante, hay un caso especial en C# que nos permite aproximarnos al tipo de autorización selectiva que friend hace posible: las clases anidadas. Para hablar con precisión, suponga que tenemos dos clases declaradas de esta manera:
public class A
{
private int campo1;
// ...
public class B
{
// ...
}
}
La clase A declara una clase anidada B. La clase anidada se declara pública, pero esto es un accidente que no tiene nada que ver con el truco que estamos tratando. Dentro del código de la clase A podemos referirnos a la clase B directamente, sin prefijos. Si queremos usar B fuera de la clase A, eso sí, tenemos que referirnos a ella como A.B. Y antes de seguir, una aclaración muy importante: este anidamiento sólo afecta al nombre y disponibilidad de la clase B; en ningún caso tiene consecuencias para el formato físico de las clases A o A.B. Quiero decir: una instancia de la clase A.B no contiene ningún tipo de puntero o referencia a instancia alguna de la clase A. Esta aclaración es necesaria para quienes tengan más experiencia con Java: este lenguaje define unas cuantas variaciones sobre el tema de las clases anidadas.
Y ahora viene el truco: el único privilegio que recibe la clase anidada A.B es el acceso a los campos, propiedades, métodos y demás recursos privados de la clase A. Imagine que tenemos el siguiente método en la clase A.B:
public class A
{
private int campo1;
// ...
public class B
{
// ...
public void MetodoAnidado(A instancia)
{
Console.WriteLine(instancia.campo1);
}
}
}
El método de la clase anidada A.B recibe una referencia a una instancia de la clase contenedora A. La novedad consiste en que la clase A.B puede acceder impunemente al campo campo1 de la clase A... ¡a pesar de estar declarado como campo privado! El resultado es parecido al que lograríamos en C++ con una cláusula friend. Eso sí, se trata de un permiso asimétrico: no hay forma de que A pueda acceder a los recursos privados de A.B.
|
No es pecado sellar una clase en un proyecto ejecutable.
|
¸ La palabra clave sealed en C# tiene la misma función que final en Java: indicar que no se permitirán reescrituras posteriores de un método o de una clase. Si usted ha sido educado en la supersticiosa idea de que la P.O.O. consiste en meter mano a cualquier método en cualquier momento y redefinirlo sin pensar en el futuro y el pasado... va a necesitar un psicoanalista.
No hay nada malo en sellar una clase. De hecho, a estas alturas de la historia de la programación, es consenso que, para ofrecer un método redefinible a los clientes potenciales de nuestras clases, hay que tener mucho cuidado. Pero si está desarrollando un proyecto ejecutable, hay menos motivos aún para dejar clases sin sellar: siempre habrá tiempo para quitar el sello. Es incluso posible que esta medida aumente la eficiencia al llamar métodos en determinadas circunstancias.
|
El contrato del método virtual Equals prohibe el disparo de excepciones.
|
& Esta es una regla del juego muy importante. El método Equals se introduce en la clase System.Object, con este prototipo:
public virtual bool Equals(object obj);
El problema es que el parámetro del método es un coladero que deja pasar cualquier cosa: una referencia nula, un puntero a una clase sin relación alguna con la "nuestra". Y en tales caso, la tentación consiste en negarnos a trabajar si no nos dan lo que pedimos: si nos pasan una referencia nula, o una instancia de una clase no prevista, lo normal sería lanzar una excepción.
Lo que ocurre es que el contrato implícito heredado de la clase System.Object exige que Equals devuelva false en todos estos casos anormales, en vez de lanzar una excepción. De manera que ya está advertido: si redefine Equals, compruebe que no se producen excepciones dentro de la nueva implementación.
|
Si define operadores sobre estructuras, vigile el tamaño de las instancias.
|
¸ Esta es una píldora relacionada con la eficiencia, y se basa en un comportamiento que he comprobado en .NET v2.0:
Dicho de esta manera, parece una perogrullada: el compilador JIT respeta la semántica de traspaso de parámetros por valor. Pero el detalle importante es la palabra "copia", y es importante porque en realidad existen dos alternativas para implementar este tipo de traspaso... y el compilador JIT elige siempre la misma.
Una de las alternativas es la evidente: si usted ha definido un struct/record y luego define un método que recibe un parámetro de este tipo, sin los modificadores ref/out, para pasar la estructura al método se copia toda la estructura en la pila, de modo que el código del método pueda acceder a esta copia utilizando un desplazamiento fácil de calcular respecto al inicio del marco de la pila. Esta copia garantiza una característica importante del traspaso de parámetros por valor: si el programador modifica el parámetro dentro del método, estos cambios no se reflejan en la instancia original de la estructura. Esta técnica es eficiente sólo cuando se trata de estructuras de pequeño tamaño.
La alternativa consiste en pasar sólo la dirección de la estructura original. ¿Y si el programador realiza cambios pensando que está trabajando con una copia? Bueno, esta es la limitación que se exige para esta optimización: sólo se puede usar esta técnica cuando el programador no modifica el parámetro dentro del método. ¿Es fácil averiguar si se cumple esta condición? Al parecer, no. En C++, para que el compilador utilice esta variante, debemos marcar el parámetro con el modificador const. El compilador verifica entonces que todas las funciones miembros (usando la jerga de C++) que invoquemos sobre la instancia respeten el estado interno de la misma... y esto, en muchos casos, consiste simplemente en comprobar que dicha función utilice el modificador const aplicado esta vez sobre el parámetro this (pregunta de control: ¿por qué he mencionado sólo C++, y no Delphi?). Para que el compilador JIT pudiese disponer de algo parecido, habría que hacer estas comprobaciones de manera automática. Es muy probable que esto haya sido considerado demasiado costoso en tiempo de ejecución, y que los autores del compilador JIT hayan decidido (acertadamente) usar siempre el traspaso por copia para implementar el traspaso por valor. Sospecho, además, que copiar la dirección habría complicado bastante la implementación del recogedor de basura.
En términos prácticos, ¿qué consecuencias tiene este hallazgo? Imagine que definimos un vector de esta guisa:
public struct Vector
{
public readonly double X, Y, Z;
// ...
}
Esta estructura debe ocupar unos 24 bytes: tres veces ocho bytes, porque cada campo de tipo double ocupa ocho bytes. Imagine ahora que definimos un operador para sumar vectores:
public static Vector operator+(Vector v1, Vector v2) { ... }
Obligatoriamente, los parámetros de un operador deben pasarse por valor, nunca por referencia. Esto significa que estos dos vectores deben copiarse en la pila antes de ejecutar el código del operador: ¡cada llamada a este operador exigirá copiar 48 bytes en el marco de activación del operador! Aunque el compilador JIT elige la implementación más eficiente para esta copia (¡incluso utiliza registros MMX cuando están disponibles!), esta copia es inevitablemente costosa. En el caso de Vector, nos movemos todavía en la frontera de lo aceptable, pero... ¿y si definimos matrices como estructuras? Si son matrices en tres dimensiones, tendremos setenta y dos bytes por cada matriz. ¿Qué piensa que ocurrirá cuando se active un operador de este tipo?:
public static Vector operator*(Matrix m, Vector v) { ... }
En estos casos, es preferible renunciar a la comodidad sintáctica del uso de operadores, definiendo la "multiplicación" de un vector por una matriz como un método regular:
public struct Matrix
{
// ...
public Vector Transform(Vector v) { ... }
}
En este caso, estamos aprovechando otro hecho comprobable: el parámetro this, que es el que utilizamos ahora para pasar la matriz al método, ¡siempre se pasa copiando su dirección!... cuando se trata de estructuras, naturalmente. ¿Quiere ahorrarse otra copia? Evalúe la conveniencia de esta definición alternativa:
public struct Matrix
{
// ...
public void Transform(ref Vector v, out Vector result) { ... }
}
¡Cuidado, que esto es muy "sucio"! El vector a transformar se pasa ahora como un parámetro de referencia, con lo cual obligamos al JIT a pasar su dirección. Esto no siempre será posible (ya no podremos pasar una expresión de tipo Vector en este parámetro), y dependiendo del contenido del método, puede que merezca o no la pena. Si el método realiza muchas referencias a campos del vector, cada acceso tendrá ahora un nivel adicional de indirección. El otro cambio ha sido la sustitución del tipo de retorno por un parámetro explícito de salida. Ocurre que cuando usamos un valor de retorno de tipo estructura, internamente se implementa también copiando el valor en la pila.
Todos los cambios propuestos empeoran la legibilidad del código fuente, y sólo debe pensar en ellos si realmente ha detectado un cuello de botella en su aplicación, no como una "optimización" a priori. En el código de XSight RT, por ejemplo, coexisten operadores con métodos que realizan la misma tarea. En las partes que no son críticas para la eficiencia, se utilizan los operadores, para que el código sea más legible. En las secciones críticas, por el contrario, se utilizan siempre los métodos equivalentes por su mayor eficiencia.
|
Las lecturas de una propiedad en una instancia deben ser repetibles.
|
& En Delphi, la lectura de una propiedad, la invocación de una función sin parámetros y el acceso a una variable son indistinguibles sintáticamente... y eso es bueno. En C#, siguiendo la tradición de C/C++, el acceso a propiedades es idéntico sintáticamente al acceso a variables, pero cuando se llama un método o función sin parámetros, hay que incluir obligatoriamente un par de paréntesis sin nada entre ellos. Para el programador de C# se plantea el dilema: tengo una función sin parámetros, ¿debo reescribirla como propiedad?
Si se trata de una función de acceso, que tiene como pareja un método de modificación correspondiente, no hay nada que analizar: es una propiedad. Pero existen propiedades de sólo lectura, que son perfectamente aceptables. Si tenemos una función que devuelve una información que no se puede luego modificar directamente, ¿sería positivo reconvertirla en propiedad?
Hay varias condiciones que se deben cumplir para ello. La que concierne a esta píldora es la repetibilidad de las lecturas. Suponga que tiene una variable X que apunta a un objeto cuya clase ofrece una propiedad Y. Esta última puede ser de lectura y escritura, o de sólo lectura: no importa ahora. Usted lee el valor de X.Y, y lo guarda en una variable local. A continuación "congela" la aplicación (luego precisaremos un poco la idea), y se va a por un café. Cuando regresa, vuelve a leer el valor de X.Y: el principio de repetibilidad exige que el nuevo valor leído sea igual al primero. ¿Por qué? Pues porque si en vez de tratase de una propiedad, Y fuese una variable, ocurriría esto que acabo de contar. Y se supone que, si es imposible distinguir sintácticamente entre variables y propiedades, tener comportamientos diferentes puede confundir al programador y producir errores de programación difíciles de arreglar.
Una precisión: ¿qué es eso de congelar la aplicación? La idea es que no se produzcan cambios "notables" en el estado de la aplicación... pero está claro que este criterio es demasiado restrictivo. En realidad, sólo deberíamos exigir que no se produjesen cambios en el estado interno del objeto cuya propiedad ponemos a prueba. E incluso este segundo criterio sigue siendo excesivo. Podríamos permitir, por ejemplo, asignaciones sobre propiedades "independientes"... pero tendríamos que definir qué entendemos por independencia evitando definiciones recursivas. Lo importante, no obstante, es la idea intuitiva: una propiedad debe comportarse de forma muy similar a un campo de la instancia.
|
Si va a definir operadores para una clase, piénselo otra vez.
|
? Se supone que los operadores definidos por el programador deben ajustarse, dentro de lo posible, a la semántica de los operadores predefinidos. El problema está en que casi todos los tipos primitivos predefinidos son estructuras (System.String, técnicamente hablando, no es un tipo primitivo según la terminología .NET). Es decir, los operadores predefinidos suelen aplicarse sobre tipos con semántica de asignación por valor.
Tenga presente que el "lenguaje de expresiones" de la matemática no es un lenguaje especialmente "orientado a objetos". El estilo orientado a objetos prefiere actuar sobre el estado interno de los objetos, mientras que el estilo funcional prefiere crear nuevas instancias en vez de actuar sobre el estado de instancias ya existentes. Si la construcción de nuevas instancias es costosa, el estilo funcional deja de resultar atractivo.
Como suele ocurrir, hay una excepción a esta regla: puede declarar operadores a la medida, sin cargos de conciencia, sobre una clase inmutable. Esto es lo que ocurre con System.String y el operador de concatenación.
Con la excepción quizás de LISP, los lenguajes funcionales no han disfrutado nunca de la popularidad merecida, pero se han mantenido vivos, siempre en un discreto segundo plano. Ahora, sin embargo, parece haber resurgido el interés en estos. ¿Cuál es el motivo? La causa está en que los lenguajes funcionales pueden modelar los sistemas de tipos de lenguajes más mainstream. Es más sencillo establecer teoremas y pruebas formales usando modelos más cercanos a los que desde siempre se han usado en las Matemáticas. ¿Quiere decir esto que puede llegar el día en que los lenguajes orientados a objetos se vean desplazados por lenguajes funcionales? No lo creo, al menos si hablamos de lenguajes funcionales puros. Lo que sí es muy probable que veamos en los próximos años lenguajes híbridos, o simplemente que algunas características tradicionales de los lenguajes funcionales, o incluso de la programación lógica, se incorporen dentro de lenguajes orientados a objetos. C# 3.0 es buena prueba de ello.
|
Si sus métodos utilizan frecuentemente rutas de objetos demasiado largas, probablemente falte una abstracción intermedia.
|
? Esta es otra de las píldoras para la paranoia... quiero decir, para combatir la sospecha de que hay algo malo en el código fuente. Suponga que tropieza con un fragmento de código como el siguiente:
if (!x.prop.isOk)
{
x.method(x.param1);
x.prop.isOk = true;
}
Es decir: si X esto, entonces ejecutamos X tal y asignamos X más cuál, pero si no.... Equis-punto por todas partes. Sin embargo, ¡se supone que estamos dentro de un método, y que el protagonista de un método es this, no el tal x, sus venturas y desventuras! Suponiendo que x es un campo, podemos verlo más claro si reescribimos el fragmento de la siguiente forma, completamente equivalente:
if (!this.x.prop.isOk)
{
this.x.method(this.x.param1);
this.x.prop.isOk = true;
}
El encapsulamiento sirve, entre otras cosas, para ahorrarle al programador detalles sobre el funcionamiento de un subsistema. Como en las organizaciones humanas, se crea un departamento y se nombra un jefe. Si alguien quiere algo del departamento, debe hablar con ese señor. En el código mostrado, sin embargo, nos saltamos constatemente a X, jefe, e insistimos en contactar directamente con sus subordinados. Si existe alguna regla interna dentro del departamento, de esta manera estaríamos ignorándola. ¿Solución? Encapsule ese fragmento dentro de un nuevo método de la clase a la que pertenece X... si es posible, claro.
No obstante, en este caso debemos ir con cuidado y evaluar la relación esfuerzo/recompensa. Si este código sospechoso sólo se ejecuta una vez, ¿merece la pena tomarse el trabajo de crear un nuevo método? Si, a pesar de estar "puenteando" al jefe, no hay nada "objetable" en lo que hacemos, ¿merece la pena el esfuerzo adicional? En verdad... muchas veces no lo merece. Use su sentido común para decidirlo.
En el Pascal clásico, y por extensión en Delphi, la instrucción with servía para disfrazar estas situaciones, creando un "departamento" sobre la marcha: una de las consecuencias es que si gritas "Pepe", te va a responder el Pepe del nuevo departamento, no los quinientos Pepes de la primera planta. No es como para escribir una filípica del tipo "With statement considered harmful"... pero no he creído que merezca la pena el esfuerzo de implementarlo en Freya. Como da la casualidad de que el SQL de DB2 (¡que es el SQL de Chamberlin!) y Transact SQL 2005 utilizan with para introducir las common table expressions, el with de Freya sirve para delimitar un bloque en el que se declaran nuevas variables.
|
Las clases con muchos datos y poco código delatan al “analista” improvisado.
|
? Supongo que estará usted tan familiarizado como un servidor con esa plaga tan extendida de nombrar directores de informática a gente cuyo único mérito es la voluntad de mando (que no es lo mismo que saber mandar). Esta epidemia es aún más dañina gracias a los vendedores de pociones mágicas y a ciertos evangelistas de UML. Estos últimos quieren que creamos que se puede mecanizar la programación. Claro: éste es el sueño de cualquier jefe de departamento, y la gente suele creerse aquello que confirma sus más íntimos deseos. UML es particularmente nocivo porque puede inducir a los profanos a creer que diseñar una aplicación compleja es algo que se puede lograr a golpe de dibujines. ¡Es que incluso Microsoft publicita Visual Studio mostrando a una panda de nerds que cubren la superficie de un parque con extraños diagramitas! En alguna "píldora" le contaré todo lo que tengo contra UML, pero no en ésta.
Los problemas aparecen cuando el analista improvisado intenta aplicar lo que él (o ella) cree que es eso de la P.O.O. (por cierto, ¿sabe lo que significa poo en inglés?). Bueno, seamos justos: incluso para un buen programador, es complicado explicar las ventajas de la Programación Orientada a Objetos. Entre otras razones, porque un buen programador asimila estas ventajas en su fuero interno, como si se tratase de su segunda naturaleza. Por lo tanto, las clases que salen de la pizarra del analista improvisado contienen, principalmente, campos y propiedades. Sin embargo, lo importante, que es saber cómo se encapsula el código, cómo se distribuyen las responsabilidades en el sistema, queda casi siempre a cargo del programador situado administrativamente en un escalón más bajo. El programador terminará resolviendo el problema... y esto sólo servirá para reforzar el prestigio de su jefe.
Es momento de ponernos serios: hay que desconfiar de las clases que sólo encapsulan datos. Si tiene en sus manos un sistema que ya funciona, intente identificar qué clases han usurpado las responsabilidades de la clase sospechosa. Si está todavía en la fase de análisis y comprensión del sistema, intente ayudarse identificando qué debe hacer la clase en cuestión: sólo después de identificar esas responsabilidades, intente mover a la clase la información necesaria para cumplir con sus tareas.
|
Sospeche de los métodos con un número excesivo de parámetros: puede que le falte alguna abstracción.
|
? Esta es una regla heurística para detectar problemas de diseño en el código fuente. En la caricatura extrema de una aplicación con diseño funcional, el estado de la aplicación fluye a través de los parámetros. En la caricatura contraria, es decir, en la aplicación ideal orientada a objetos, muy pocos métodos tienen dos o más parámetros, y el estado se almacena en los objetos. Las caricaturas extremas suelen ser falsas, pero son útiles para detectar tendencias y movimientos ocultos dentro del espacio de diseño.
¿Qué hacer si te encuentras con un método que tiene tropecientos parámetros?
- Comprueba, en primer lugar, si la misma combinación de parámetros se da en otras llamadas. Es muy probable que estos parámetros deban agruparse en una clase. No se trata de un criterio suficiente para crear una nueva clase: habría que tener en cuenta si la nueva clase tendría funcionalidad propia "interesante" o si sólo serviría para agrupar estos parámetros.
- En otros casos parecerá que los parámetros tendrían que formar parte, en realidad, del objeto que declara el método. Esto ocurriría si, por ejemplo, hay un primer método que recibe dicho conjunto de parámetros y sirve de raíz para una serie de llamadas que reciben una y otra vez estos parámetros. En este caso, no obstante, sería bueno analizar los tiempos de vida, o más bien, en qué intervalo del ciclo de vida de un objeto el estado determinado por los nuevos campos sería válido. En la mayoría de los casos, la respuesta será nuevamente la del punto anterior: el estado debe pasar a una nueva clase.
En varios libros he mencionado, como ejemplo paradigmático de este hecho, el método utilizado por el Borland Data Engine (BDE) para abrir una tabla en una base de datos. En el BDE, hay un método dbiOpenTable que tiene nada menos que catorce parámetros. Al encapsular el BDE en una biblioteca de clases, dbiOpenTable se convirtió en el método Open (¡sin parámetros!) de la clase TDataSet.
|
Si para crear un objeto no basta un constructor, es muy probable que necesite una fábrica de clases.
|
? Es curioso que la clase TDataSet de Delphi, que acabo de alabar, sirva para ilustrar, no obstante, otro fallo de diseño habitual. Los catorce parámetros de dbiOpenTable se transformaron en otras tantas propiedades de la clase TDataSet... pero se produce un extraño fenómeno: en cuanto se llama al método Open, ya no podemos modificar estas propiedades. Otros métodos y propiedades, por el contrario, no pueden usarse mientras no hayamos abierto la tabla. Es como si TDataSet sufriese de personalidad dividida.
Lo que sucede, en efecto, es que TDataSet agrupa dos clases relacionadas:
- El conjunto de datos en sí, que permite recorrer las filas de una tabla, insertar nuevos registros, modificar el registro activo...
- Una segunda clase que podríamos llamar TableCreator, o DataSetCreator cuyo objetivo es crear una instancia de la clase hipotética antes mencionada.
Esta sería una aplicación clara del patrón clásico conocido como Factory. En el caso más general, tendríamos una doble jerarquía, de clases de conjuntos de datos por un lado, y de creadores de conjuntos de datos, por el otro. La correspondencia entre las dos jerarquías, en general, no tendría que ser biunívoca.
¿Por qué no se ve este patrón con mayor frecuencia? En primer lugar, por la proliferación de clases que se produciría. Pero este es un problema menor. Más importante es el conflicto con la metodología de trabajo con diseñadores visuales. Usted coloca un DataSetCreator sobre un formulario, y prepara las propiedades necesarias para que genere una instancia de DataSet en tiempo de ejecución. ¿Tendríamos que situar un componente sobre la superficie de diseño para representar el conjunto de datos? Si no lo hacemos, lo tendremos difícil para interceptar eventos del componente en tiempo de diseño. Si lo hacemos, volveremos a tener un componente con dos caras, que estaría en estado "zombie" hasta que el DataSetCreator correspondiente le insuflase aliento vital.
Hay otro problema menor, aunque interesante. Imagine cuál sería el prototipo del método definido en un DataSetCreator para generar el DataSet:
public abstract class DataSet { ... }
public abstract class DataSetCreator
{
// ...
public abstract DataSet CreateDataSet();
// ...
}
Imagine ahora un par de clases derivadas:
public class SqlDataSet: DataSet { ... }
public class DataSetCreator : SqlDataSetCreator
{
// ...
public override SqlDataSet CreateDataSet();
// ...
}
En este preciso momento, es imposible redefinir el método CreateDataSet tal y como hemos mostrado... a no ser que estemos programando en Eiffel (es también una propuesta opcional del actual estandar para C++). Observe que el problema está en que, al redefinir el método, hemos especializado el tipo de retorno: del DataSet original al SqlDataSet de la nueva versión. Esto es un ejemplo de redefinición covariante del tipo de retorno, y .NET, de momento, no lo soporta... a pesar de tratarse de una técnica matemáticamente "segura". Es cierto que se permite la covarianza de tipos de retorno (y la contravarianza de los parámetros de entrada) cuando se trata de la compatibilidad entre tipos delegados. Sería posible para un lenguaje (como realmente hace Eiffel.NET) permitir este tipo de redefiniciones. Lo que no habría es la posibilidad de respetar estas redefiniciones desde otros lenguajes que utilizasen las clases definidas en el lenguaje covariante.
|
Es pecado capital utilizar una negación en el nombre de un miembro de una clase.
|
? Un componente de acceso a datos en Delphi tiene una propiedad llamada NoMetadata. Si en NoMetadata asignamos False, ¿se leerán los "metadatos" desde el cliente cuando se active la consulta? ¿Cuántos segundos ha perdido calculando la respuesta? ¿Está seguro de no haberse equivocado?
|
Encapsular no es esconder secretos de la implementación, sino minimizar las dependencias entre creador y consumidor.
|
? A todos nos gusta jugar a ser James Bond, pero el sentido de la O.O.P. no va por ahí. El objetivo real de todas estas técnicas es facilitar la creación de aplicaciones mejores y más baratas, y la mejor forma de lograrlo es lograr programas que haciendo lo que se les piden, tengan la mínima complejidad posible. Cuando declaramos un campo, una propiedad o un método privado, lo que estamos haciendo se parece más a poner la lejía fuera del alcance de los niños que a guardar secretos de Estado...
... y a pesar de que esto pueda parecer trivial, hay quien se horroriza todavía porque la reflexión en .NET permita obtener información sobre miembros privados de un tipo de datos. Si usted realmente tiene algún algoritmo secreto que debe implementar sobre la plataforma .NET y quiere evitar que el algoritmo termine en malas manos, lo que necesita es otra técnica, llamada ofuscación.
|
No es necesario que una clase tenga un método o una propiedad abstracta para declararla también abstracta.
|
? En C++, se considera que una clase es abstracta cuando contiene métodos virtuales puros; esto es, cuando no proporcionamos un cuerpo para alguno de sus métodos virtuales. La consecuencia de esto es que no podemos crear instancias de estas clases, que sólamente existen para derivar clases concretas de las mismas por medio de la herencia. Esta es una idea estupenda, pero como suele ocurrir, el diseño de C++ la plasma bastante mal. De entrada, la sintaxis necesaria para declarar funciones virtuales "puras" es muy suya:
virtual void LoQueSea() = 0; // C++
¡Muy "natural" considerar que un cero indica que no hay implementación! Hay más problemas, claro: para saber si una clase es abstracta, hay que revisar la potencialmente larga lista de métodos declarados en ella o incluso aquellos heredados de un ancestro. Las cosas no son mucho mejores en Delphi, sino todo lo contrario. Un método en Delphi puede declararse con el modificador abstract, lo cual ya es un avance. Pero esto no significa nada en lo que concierne a la clase: podemos seguir creando instancias de la clase... pero si tenemos la desgracia de llamar por descuido o ignorancia a uno de estos métodos aparentemente "abstractos", ¡provocaremos una excepción! Por cierto, la excepción irá asociada a un clarificador mensaje de error: Abstract error.
El diseño de esta característica en C# es mucho más elegante. Podemos declarar métodos abstractos y propiedades abstractas:
// Un método abstracto
public abstract void LoQueSea(); // C#
// Propiedad abstracta de sólo lectura
public abstract int UnaPropiedad { get; }
A diferencia de lo que ocurre en C#, para poder declarar miembros abstractos, tenemos que marcar explícitamente la clase como tal:
public abstract class ClaseBase { ... }
Un buen diseño siempre recompensa: gracias a este diseño, C# permite declarar clases abstractas, ¡aún cuando no se declaren miembros abstractos en su interior! Basta que deseemos que no se puedan crear instancias de una clase que sólo queremos usar como base para la herencia: nos bastará marcarla con el modificador abstract. En estos casos se recomienda, por claridad de la programación, que los constructores de esta clase se declaren protegidos. A pesar de que la clase es abstracta, estos constructores serán necesarios para la construcción de clases derivadas. Aunque no es un error declararlos públicos, esto podría despistar al programador que la utilizará, por lo que se aconseja declarar estos constructores como miembros protegidos (protected).
|
Utilice estructuras sólo si el estado en el que todos los campos están inicializados a cero es correcto.
|
& Esto es un corolario elemental de las reglas de juego de .NET. En esta plataforma, los tipos con semántica de asignación por valor (struct, en C#, record en Freya) tienen automáticamente un constructor sin parámetros. De hecho, ¡no es posible declarar un constructor sin parámetros para ellos! Este constructor hace lo que se puede imaginar: barrer con ceros el espacio utilizado por la instancia. Por lo tanto, al definir una estructura hay que contar con el hecho de que no podemos evitar la "construcción" de instancias de la estructura con el constructor por omisión. Suponga que la estructura que quiere declarar debe tener un campo que indique si la instancia es válida o no. Suponga que utiliza esta declaración:
public struct MyStruct
{
private bool isValid;
// ...
}
Para empezar, C# no admite que asociemos un inicializador al campo. De hacerlo así, estaríamos declarando implícitamente un cuerpo para el constructor sin parámetros y eso es imposible. Con el constructor por omisión, isValid comenzaría su existencia con el valor false. Si esto es aceptable, no hay más que hablar. En caso contrario, puede que tenga que invertir el sentido del campo:
private bool isInvalid;
Pero puede que esto no sea de su agrado: de hecho, una regla importante de la buena programación es evitar el uso de negaciones en identificadores. En tal caso, lo que puede hacer es crear una propiedad que invierta el significado del campo:
private bool isInvalid;
public bool IsValid { get { return !isInvalid; } }
Cuando el compilador de Freya encuentra una llamada al constructor implícito de una estructura, lo traduce a una llamada a la instrucción initobj del Lenguaje Intermedio. Al fin y al cabo, la memoria que necesita la instancia ya existe en ese momento, y lo único que se necesita es limpiar los campos de la zona (en ocasiones, ni siquiera eso, pero de esta optimización se encargará el compilador JIT). Incluso las llamadas a otros constructores de la estructura se manejan de forma diferente a como se hace con los constructores de clases: en vez de utilizar la instrucción newobj, se ejecuta un simple call, como si el constructor fuese un método más de la clase.
|
Si define operadores binarios, documente con claridad si son conmutativos o no.
|
& En la suma matemática de vectores, da igual el orden entre los operandos. Por el contrario, la multiplicación de matrices es asociativa, pero no conmutativa. Cuando defina operadores para una clase o estructura, es sumamente conveniente que informe a los potenciales clientes del tipo de datos sobre el comportamiento de los operadores ofrecidos. Por desgracia, la mayoría de los lenguajes no ofrece un mecanismo formal para especificar este tipo de comportamiento, pero al menos puede usar los comentarios XML, o incluso atributos personalizados, para darle un carácter algo más formal a la especificación.
Freya ofrece dos atributos, Commutative y Associative para marcar operadores. Estos atributos, en primer lugar, ofrecen una valiosa información sobre el contrato implementado por los operadores. La incorporación de estos atributos merece la pena incluso aunque se tratase de la única ventaja ofrecida. En segundo lugar, pueden ser usados por el compilador para aplicar optimizaciones que afectan el orden de evaluación. Es cierto que en estos momentos el compilador no lleva a cabo este tipo de optimizaciones, y que las condiciones para que se puedan aplicar son complicadas, entre otros factores porque hay que excluir la posibilidad de que se introduzcan efectos secundarios imprevistos. En tercer lugar, cuando un operador binario utiliza operandos de tipos diferentes y a pesar de ello se declara conmutativo, como en el caso de la multiplicación de un vector por un escalar, el compilador añade automáticamente la segunda implementación.
¿Por qué una segunda implementación, en el último caso? En teoría, bastaría la única implementación existente, si el compilador tiene el cuidado de intercambiar los operandos cuando sea necesario. La clave está en la posibilidad de usar estos operadores desde otro lenguaje .NET, algo que garantiza la segunda implementación. Además, no siempre será posible intercambiar el orden de los operandos sin introducir efectos secundarios.
|
Evite la construcción de cadenas de caracteres mediante concatenaciones sucesivas. Utilice, en cambio, la clase StringBuilder.
|
¸ El tipo System.String de .NET es una clase inmutable. Esto significa que, una vez creada una instancia, no existe forma alguna de modificar su valor. Tomemos un ejemplo sencillo y cotidiano:
string s = "C#";
s = s + " rules";
Aquí estamos hablando nada menos que de tres objetos de la clase string, y dos de ellos terminan sirviendo de alimento al recolector de basura. Es cierto que, en este caso particular, esos dos objetos son constantes literales, que el entorno representa de manera especial. Pero si seguimos construyendo una cadena larga mediante concatenaciones sucesivas, terminaremos con un montón de objetos de cadenas usados y tirados.
Para evitar el vapuleo a la memoria dinámica, existe la clase System.Text.StringBuilder. Esta clase implementa una caché interna, en la que podemos agregar cadenas, siempre al final del resultado intermedio. Este método pertenece al compilador de Freya, y muestra cómo usar un StringBuilder para sintetizar el nombre completo de un tipo genérico "cerrado":
private static string GetFullName(
Type generic, Type[] arguments)
{
StringBuilder sb = new StringBuilder(generic.FullName);
sb.Append("[");
for (int i = 0; i < arguments.Length; i++)
{
if (i > 0) sb.Append(",");
sb.Append(arguments[i].FullName);
}
sb.Append("]");
return sb.ToString();
}
Otro consejo: dedique un día a repasar y memorizar los métodos ofrecidos por la clase string. Muchos de ellos, como el método Join, simplifican operaciones de uso frecuente, y recurren a trucos de bajo nivel para mejorar los tiempos de ejecución.
|
Simule constantes con tipos complejos mediante campos estáticos de sólo lectura.
|
? C# nos permite declarar constantes literales para los tipos habituales: cadenas, enteros, reales y valores lógicos. La idea es que estos valores puedan ser utilizados en tiempo de compilación. ¿Qué pasa si uno crea una clase o estructura y tiene la necesidad de declarar "constantes" para el nuevo tipo? Aunque es posible declarar constantes para clases, el único valor admitido para estas es el puntero nulo.
En estos casos, la técnica aconsejable es declarar campos estáticos de sólo lectura. Al tratarse de un campo estático, no hace falta crear una instancia de la clase o estructura para acceder al campo. Por otra parte, los campos marcados como readonly no pueden ser modificados fuera del constructor de la clase; al tratarse de static readonly, estamos exigiendo en realidad que sean modificados durante la inicialización del tipo (o, en la práctica, mediante un inicializador de campos estáticos).
El ejemplo a continuación ilustra la técnica:
public struct Punto
{
public static readonly Punto Origen =
new Punto(0, 0, 0);
public static readonly Punto EjeZ =
new Punto(0, 0, 1);
// ...
}
El programador declara una estructura Punto, y declara dos campos dentro de la misma para representar el origen de coordenadas y uno de los ejes tradicionales. Observe que esta técnica funciona mejor si el tipo de datos es inmutable, porque se evita el peligro de que algún desaprensivo modifique el estado interno de la instancia que representa a la constante.
No obstante, es importante que comprenda la diferencia entre una constante verdadera y nuestro apaño. Suponga que declara una constante en un ensamblado. Alguien utiliza el ensamblado y escribe una aplicación que usa dicha constante. En realidad, habrá usado el valor de dicha constante en el momento de la compilación. Si con posterioridad, usted crea una nueva versión del mismo ensamblado y modifica el valor de dicha constante, las aplicaciones que se compilaron con la versión original no se enterarán del cambio... a no ser que se vuelvan a compilar. Esto no ocurriría, por supuesto, si la constante se hubiera declarado por medio de un campo estático de sólo lectura. ¿La moraleja? Declare constantes sólo cuando tenga la certeza absoluta de que su valor nunca será modificado.
Freya no se aparta demasiado de C# en el tratamiento de constantes y campos estáticos de sólo lectura, con la excepción de que no es obligatorio declarar el tipo de una constante. Una novedad son los tipos inmutables: si una clase se declara con el modificador const, todos sus campos reciben automáticamente el modificador readonly; de hecho, si utiliza readonly explícitamente en uno de sus campos, el compilador se quejará. Del mismo modo, cuando una clase se declara static, métodos, campos y propiedades se asumen también estáticos... pero a diferencia de lo que ocurre en C#, no hay que usar (¡no se puede usar!) el modificador static en cada declaración de miembro.
|
No escriba constructores estáticos mientras pueda arreglárselas con inicializadores para los campos estáticos.
|
¸ Esta es una curiosa característica de C#. Cuando declaramos campos estáticos en una clase y les asociamos inicializadores, C# genera las correspondientes asignaciones y las utiliza para generar un constructor estático (llamado también inicializador de tipo). Si el programador ha creado también un inicializador de tipo explícito, las asignaciones relacionadas con la inicialización de campos se añaden al cuerpo constructor, antes de que comiencen a ejecutarse las instrucciones explícitas. La siguiente clase contiene un inicializador de tipo y un par de campos estáticos con inicializadores:
class ClassWithStatics
{
private static int entero = -1;
private static string cadena = "Uno";
private static int tickCount;
static ClassWithStatics()
{
tickCount = System.Environment.TickCount;
}
}
A partir de esta clase, el compilador genera un constructor estático más extenso:
static ClassWithStatics()
{
entero = -1;
cadena = "Uno";
tickCount = System.Environment.TickCount;
}
Si no hay un constructor estático explícito, el compilador genera uno. Y esta sencilla encrucijada determina el valor de un modificador muy influyente en el diseño de la clase:
- Cuando no hay constructor estático explícito, la clase se marca con el modificador beforefieldinit.
- Cuando no hay constructor estático explícito, no se utiliza el modificador mencionado.
¿Y? Al parecer, cuando una clase se marca con beforefieldinit, el entorno de ejecución tiene más libertad para determinar cuándo hay que ejecutar el inicializador del tipo. En realidad, sospecho que esta inicialización tiene lugar al arrancar la aplicación. Cuando no se usa beforefieldinit, la inicialización del tipo tiene lugar justo antes de que alguien, por vez primera, acceda a una instancia de la clase. Esta semántica es mucho más estricta, y obliga a comprobar cada acceso a los miembros de la clase para ejecutar el inicializador si fuese necesario. De acuerdo a esto, si creásemos un constructor estático de manera explícita, estaríamos ralentizando el acceso a los miembros de la clase afectada.
Le advierto, no obstante, que no he logrado comprobar esta teoría en .NET 2.0, pero es cierto que no he hecho pruebas exhaustivas: hay que comprobar la diferencia entre compilar en modo DEBUG y RELEASE, entre usar ngen.exe o no; incluso puede influir la clase base elegida para la herencia. Sí he comprobado la diferencia en el código generado, y esta advertencia es "oficial" y los efectos están bien documentados.
De momento, Freya sigue la misma vía que C# respecto al uso de beforefieldinit. No obstante, es muy probable que cambiemos la técnica: por omisión, todas las clases se declararían con este modificador, y por medio de atributos especiales, el programador tendría la opción de eliminar este modificador cuando lo creyese conveniente.
|
|
|