C# es uno de los lenguajes de programación más utilizados hoy en día: el lenguaje de Microsoft se ha ganado ciertamente un hueco en el mercado desde su creación a principios de la década de 2000. Inicialmente diseñado como una especie de Java, poco a poco ha ido evolucionando hasta tener claramente su propia personalidad.
Una de las características más distintivas de C# son sus propiedades: estas nos permiten acceder, de manera controlada, a los atributos de una clase de terminada. Pero esta no es la única funcionalidad de una propiedad, y, de hecho, una propiedad no tiene ni siquiera por qué estar asociada a un atributo, y servir de distintas formas para ofrecer varias funcionalidades en nuestra clase. Después de todo, las propiedades no son más que métodos get y set ocultos, y sabemos que un método puede hacer muchas más cosas que retornar directamente un atributo.
class Person {
public string Name {
get; set;
}
public string Surname {
get {
string toret = this.Name;
int posComa = this.Name.IndexOf( ',' );
if ( posComa >= 0 ) {
toret = this.Name.Substring( 0, posComa );
} else {
int posSpace = this.Name.IndexOf( ' ' );
if ( posSpace >= 0 ) {
toret = this.Name.Substring( posSpace + 1 );
}
}
return toret;
}
}
public override string ToString() => this.Name;
}
En la clase Persona tenemos dos propiedades: la primera se relaciona con lo que cualquier principiante en C# haría, una propiedad automática con la que sostenemos la funcionalidad de cambiar y consultar el nombre. La segunda propiedad es la más interesante: no se relaciona con ninguna atributo, y de hecho realiza una tarea relativamente compleja: retornar el apellido de la persona, incluso decidiendo la forma de extraerlo según si el formato es apellido1 apellido2, nombre
, o nombre apellido1 apellido2
.
Decidir cuál es el apellido de una persona es un cierto trabajo que podríamos pensar en evitarnos si ya lo hemos hecho antes, guardándolo por tanto en un atributo. Esto supone complicarlo un poco, de manera que incluso de forma aparente la propiedad Surname tenga ya un atributo relacionado. Pero insisto, la idea es realizar un caching, es decir, si ya hemos resuelto cuál es el apellido de esta persona, no calcularlo de nuevo.
class Person {
public string Name {
get {
return this.name;
}
set {
this.name = value;
this.surname = string.Empty;
}
}
public string Surname {
get {
if ( string.IsNullOrEmpty( this.surname ) ) {
this.surname = this.Name;
int posComa = this.Name.IndexOf( ',' );
if ( posComa >= 0 ) {
this.surname = this.Name.Substring( 0, posComa );
} else {
int posSpace = this.Name.IndexOf( ' ' );
if ( posSpace >= 0 ) {
this.surname = this.Name.Substring( posSpace + 1 );
}
}
}
return this.surname;
}
}
public override string ToString() => this.Name;
string surname;
string name;
}
Y así es. ¿Por qué no se puede decir que la propiedad de solo lectura Surname está directamente relacionado con el atributo surname? Fijémonos en que la propiedad Name también ha cambiado: cuando se guarda un nuevo nombre, el atributo surname se pone a cadena vacía para que cuando se llame a Surname este vuelva a calcularse.
Pero hay un caso particular más interesante todavía y es... ¿qué pasa si se retorna un objeto mutable (es decir, que puede ser modificado)?
A continuación, podemos ver la clase Vector, un ejemplo tan original que seguramente el lector no se habrá encontrado nunca.
class Vector<T> {
public Vector(int capacity = 1)
{
this.v = new List<T>( capacity );
}
public void Add(T x)
{
this.v.Add( x );
}
public List<T> Elements {
get {
return this.v;
}
set {
this.v = value;
}
}
List<T> v;
}
Aquí tenemos un ejemplo típico de una propiedad escrita de manera precoz: de hecho, tal y como está escrita, podría haberse creado de una manera mucho más directa, sin necesidad de crear el atributo v.
class Vector<T> {
public Vector(int capacity = 1)
{
this.Elements = new List<T>( capacity );
}
public void Add(T x)
{
this.Elements.Add( x );
}
public List<T> Elements { get; set; }
}
Y así el autor de esta clase pretendería que utilizáramos la clase Vector de la forma más abajo. Y funciona. ¿No?
var v = new Vector<int>();
v.Add( 1 );
v.Add( 2 );
foreach(int x in v.Elements) {
Console.WriteLine( x );
}
Muchos de mis alumnos, una vez que explico propiedades, asumen que estas siempre están relacionadas con un atributo. Siempre. Y esto no solo tiene que ser así, como ya hemos visto antes, sino que puede llevar a errores como el que se muestra en ambos ejemplos de código más arriba. La razón es que por un lado encapsulamos un objeto List<T>
en nuestra clase Vector, pero por otra parte… ¡permitimos cualquier acceso a ella! Se puede incluso cambiar el objeto List<T>
interno por otro, en cualquier momento.
var v = new Vector<int>();
v.Add( 1 );
v.Add( 2 );
v.Elements = new Vector<int>();
v.Add( 3 );
foreach(int x in v.Elements) {
Console.WriteLine( x );
}
De hecho, el código más arriba se aprovecha de la debilidad de la clase para cambiar el objeto lista por otro (el original se torna irrecuperable), y por tanto, solo se muestra un valor: 3.
Tiene fácil solución: quitamos el set. ¿No? Pues no. De hecho, este problema (que es aplicable a cualquier lenguaje de programación) ya fue comentado por Eric Lippert, hace años: arrays considered somewhat harmful. Veamos:
var v = new Vector<int>();
v.Add( 1 );
v.Add( 2 );
v.Elements.Clear();
foreach(int x in v.Elements) {
Console.WriteLine( x );
}
Ahora no se muestra nada por pantalla. La razón es que limpiamos, con el método List.Clear()
, todos los elementos que hayamos introducido previamente. ¡Y no hemos tenido que cambiar un objeto por otro!
Si bien Lippert nos habla de arrays, con un título bastante provocativo, en realidad el mensaje es más general y nos presenta una aterradora realidad de problemas mucho mayores: lo cierto es que no hay nada malo en retornar un array, sino en devolver cualquier objeto mutable, cuando por dieño no queremos que tal objeto sea… mutado.
En el ejemplo más arriba, solo queremos dar acceso a dos funcionalidades en nuestra clase Vector: añadir elementos, y recorrerlos mediante un foreach
gracias a los iteradores. Sin embargo, al retornar directamente el objeto subyacente (es decir, List que es el que soporta la funcionalidad de Vector), estamos ofreciendo toda la funcionalidad que soporta List, y no solamente soporte para adiciones y recorrido.
Está claro que no es posible crear propiedades a ciegas. El hecho de que existan atajos para propiedades automáticas no significa que debamos usarlas siempre. Debemos en cambio, al igual que si estuviésemos creando métodos get() y set() (al fin y al cabo, eso es lo que son), plantearnos exactamente cuales son las funcionalidades que deseamos ofrecer, y ceñirnos a esas funcionalidades en el momento de diseñar la interfaz pública de la clase.
De acuerdo, esto es un problema, pero... ¿cómo solucionarlo?
class Vector<T> {
public Vector(int capacity = 1)
{
this.v = new List<T>( capacity );
}
public void Add(T x)
{
this.v.Add( x );
}
public T[] Elements {
get {
return this.v.ToArray();
}
}
List<T> v;
}
Pero... un momento. ¿Los arrays no son considerados peligrosos? En realidad, los arrays en sí no son peligrosos… lo peligroso es dar acceso a la parte de tu clase que da soporta a la funcionalidad (pública) de la misma.
Así, podríamos decir que retornar cualquier elemento mutable puede ser peligroso por sí mismo. Pero... pero... ¡entonces estamos agravando el problema! En realidad, no, puesto que estamos devolviendo un array que resulta ser una copia de la secuencia de objetos dentro de List que implementa la clase. Es decir, no es el array subyacente en List. El usuario de esta clase puede cambiar lo que quiera el array retornado por la propiedad Elements, puesto que al fin y al cabo ya no tiene relación con el objeto original.
Otra posibilidad es cambiar la propiedad Elements para que devuelva una copia de la lista, algo que podemos lograr con:
class Vector<T> {
public Vector(int capacity = 1)
{
this.v = new List<T>( capacity );
}
public void Add(T x)
{
this.v.Add( x );
}
public List<T> Elements {
get {
return new List<T>( this.v );
}
}
List<T> v;
}
Solo con pasar la lista original en el constructor obtenemos una copia que puede ser modificada tanto como se quiera.
Por supuesto hay otras posibilidades, como por ejemplo que en este caso List tiene una variante de solo lectura que es posible obtener con el método AsReadOnly().
class Vector<T> {
public Vector(int capacity = 1)
{
this.v = new List<T>( capacity );
}
public void Add(T x)
{
this.v.Add( x );
}
public ReadOnlyCollection<T> Elements {
get { return this.v.AsReadOnly(); }
}
List<T> v;
}
Así, pese a lo que pueda parecer, el código más abajo visualiza 1, 2... Incluso aunque hayamos cambiado un elemento anodino del array con el símbolo del sentido de la vida, el universo… y todo.
var v = new Vector<int>();
v.Add( 1 );
v.Add( 2 );
v.Elements[ 1 ] = 42;
foreach(int x in v.Elements) {
Console.WriteLine( x );
}
Sin embargo, como hemos comentado más arriba, el código es perfectamente seguro... ¿no?
Bueno, quizás no del todo. La secuencia de objetos en sí está segura dentro del objeto Vector, pero… ¿qué pasa con los objetos en sí? Pues... depende. Si los objetos son inmutables, no hay problema. Pero si los objetos son mutables, entonces estamos en la parte salvaje de la vida. Nada que podamos hacer hará que nuestros objetos sean intocables.
Por ejemplo, si retomamos la clase Persona, está claro que podemos cambiar el nombre tanto como queramos.
var v = new Vector<Person>();
v.Add( new Person{ Name = "Baltasar" } );
v.Elements[ 0 ].Name += ", el mago";
foreach(Person x in v.Elements) {
Console.WriteLine( x );
}
Lo que se visualiza es "Baltasar el mago", sin importar que devuelvas un array o una ReadOnlyCollection en Elements.get. Esto puede ser algo deseado (el diseño del programa lo permite, es decir, no tiene por qué ser un problema de por sí), o un verdadero quebradero de cabeza (cuando se desea y se asume que la colección es inmutable en todos los sentidos). Las dos únicas soluciones a este problema son: o bien modificar Person para que no se pueda cambiar su nombre (Name solo tendría get), o bien copiamos también los objetos Person, además de la lista subyacente, como se puede ver en los códigos provistos a continuación.
class Person: ICloneable {
public string Name {
get; set;
}
public string Surname {
get {
string toret = this.Name;
int posComa = this.Name.IndexOf( ',' );
if ( posComa >= 0 ) {
toret = this.Name.Substring( 0, posComa );
} else {
int posSpace = this.Name.IndexOf( ' ' );
if ( posSpace >= 0 ) {
toret = this.Name.Substring( posSpace + 1 );
}
}
return toret;
}
}
public object Clone()
{
return this.MemberwiseClone();
}
public override string ToString() => this.Name;
}
Sí, la cosa empieza a complicarse. De hecho, ni siquiera Microsoft aconseja el uso de la interfaz ICloneable (según parece, debido a la falta de información sobre si se realiza una copia superficial (shallow copy, el tipo que se puede ver en listado de arriba), o profunda (deep copy)). Es, además, un tanto incómoda: no tiene equivalencia genérica, y por tanto obliga a devolver object. Además… tendremos que cambiar nuestro vector para copiar no solo la estructura de datos, sino también los objetos en sí.
class Vector<T> where T: System.ICloneable {
public Vector(int capacity = 1)
{
this.v = new List<T>( capacity );
}
public void Add(T x)
{
this.v.Add( x );
}
public ReadOnlyCollection<T> Elements {
get {
return this.v.ConvertAll( p => (T) p.Clone() ).AsReadOnly();
}
}
List<T> v;
}
Hay varias cuestiones a tener en cuenta aquí: en primer lugar, nuestro vector ya no es válido para enteros (puesto que no son clases que implementen ICloneable). En cuanto a Elements, no debemos dejarnos engañar por el hecho de que toda la copia se haga en una sola línea: se está creando una nueva colección, de manera que para crearla se copian todos los objetos, y además, se vuelve a crear una segunda colección que será de solo lectura. Si bien al final seguimos estando en O(n) tanto en rendimiento como en uso de memoria, no debe escapársenos que ahora estamos realizando más de una copia, y con la copia de cada objeto por elemento.
¿Ya estamos seguros? Nos ha costado llegar hasta aquí, pero... hemos conseguido un código a prueba de balas, ¿verdad?
Lo cierto es que no... o no del todo. Hemos conseguido un código que es bastante seguro, pero que nunca podrá ser confiable del todo… ¿por qué? La respuesta es el método MemberwiseCopy(). Copia el objeto miembro a miembro, incluso si se trata de una referencia a otro objeto. Así, si nuestra clase contiene como atributo un List, como resultado tendremos tanto el objeto original como el objeto copiado apuntando a la misma lista. Las modificaciones, por tanto, de esta lista en el objeto original se reflejarán en el objeto copiado, y viceversa. Para poder conseguir esto, necesitaríamos un código específico para cada clase que se encargue de duplicar los objetos referenciados desde nuestro objeto a copiar, puesto que no disponemos de un método DeepCopy().
Conclusiones: ¿qué podemos aprender de todo esto?. Está claro que debemos tener en cuenta varias factores, por este largo viaje que hemos realizado a través de las propiedades, una de las características más llamativas de C#.
- Las propiedades tan solo esconden métodos específicos get() y set(value).
- Pueden hacer cualquier tarea que un método puede hacer.
- No tienen por qué corresponderse con un atributo de la clase. ¡Elimina esa equivalencia de tu cabeza!
- El hecho de limitar la mutabilidad en nuestras clases tanto como se pueda, siempre es buena idea, pues simplifica el diseño del programa.
- Retornar un objeto mutable siempre debe disparar una alarma en nuestra cabeza: ¿estoy tomando en cuenta todas las cautelas necesarias?
- Tú mismo sigues siendo, pase lo que pase y utilices el lenguaje que utilices, el "compilador" que debe conocer qué ofrece el lenguaje de programación, y cómo funciona el programa, para evitar violar ese diseño.
- Los lenguajes basados en tipos proporcionan una ayuda muy buena en cuanto a conseguir cierto comportamiento mínimo una vez que el programa compila. Sin embargo, eso no significa que por el hecho de que compile el programa esté bien. Los errores lógicos, y especialmente, los errores resultantes de violar el diseño del programa, nunca está garantizado que se eviten por el hecho de que compile.
Top comments (0)