Introducción: la trampa en la que muchos han caído
\nImagine la situación: está escribiendo una aplicación para una biblioteca en línea. Necesita su propia clase Library que almacene libros, pueda ordenarlos, buscarlos y mostrar estadísticas. Lo primero que viene a la mente es heredar de List<Book>. Después de todo, ya tiene todo: Add, Remove, indexación, búsqueda. ¿Lógico? Parece que sí.
Pero los desarrolladores experimentados de C# le dirán: «Nunca heredes de List<T>». Esta pregunta en Stack Overflow ha obtenido miles de visitas, y no es sin razón. Detrás de la aparente simplicidad se esconden serios problemas con la arquitectura, el rendimiento y, lo más importante, con la violación de los principios de diseño orientado a objetos.
\nEn este artículo, echaremos un vistazo bajo el capó de List<T>, entenderemos por qué heredar de él es un antipatrón y qué usar en su lugar. ¿Listos? ¡Allá vamos!
¿Qué tiene de malo List<T> desde el punto de vista del diseño?
\nComencemos con lo principal: List<T> no fue diseñado como una base para una jerarquía, sino como una implementación concreta y de alto rendimiento de un arreglo dinámico. Su API pública es un conjunto de métodos para trabajar con colecciones, no un contrato para la extensión.
Observe su declaración en las fuentes de .NET (simplificada):
\npublic class List<T> : IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable\n{\n private T[] _items;\n private int _size;\n \n public void Add(T item) { /* ... */ }\n public bool Remove(T item) { /* ... */ }\n // ... docenas de otros métodos\n}\nNote que List<T> no está marcado como abstract. No proporciona métodos virtuales para sobrescribir (con la excepción de algunos, de los que hablaremos más adelante). Esto significa que no puede cambiar su comportamiento de forma segura. Si sobrescribe Add, corre el riesgo de romper la lógica interna que depende de este método.
Problema n.º 1: Violación del Principio de Sustitución de Liskov (LSP)
\nEl Principio de Sustitución de Liskov dice: «Una clase derivada debe complementar, no reemplazar, el comportamiento de la clase base». Cuando hereda de List<T>, promete que su clase se puede usar en cualquier lugar donde se espere un List<T>. Pero esto rara vez se cumple.
Ejemplo: crea una clase ReadOnlyLibrary : List<Book> y sobrescribe Add para que lance una excepción:
public class ReadOnlyLibrary : List<Book>\n{\n public new void Add(Book book)\n {\n throw new InvalidOperationException("¡Colección de solo lectura!");\n }\n}\n\n// En algún lugar del código:\nList<Book> list = new ReadOnlyLibrary();\nlist.Add(new Book()); // ¡Ups! ¡No se lanzará ninguna excepción!\n// Porque new no sobrescribe, sino que oculta el método.\n// Se llamará a List.Add, no al nuestro.\nIncluso si usa override (y List.Add no es virtual), no podrá prohibir la adición: la interfaz IList no lo prevé. Simplemente romperá el contrato.
Problema n.º 2: Violación de la encapsulación
\nAl heredar de List<T>, expone toda su API pública hacia el exterior. Su clase Library obtendrá métodos como Sort, BinarySearch, Reverse, Insert, etc. Pero, ¿qué pasa si quiere que los libros estén siempre ordenados por autor? El usuario de su clase podrá llamar a library.Reverse() y romper la invariante.
No podrá evitarlo sin reescribir todos los métodos, y hay docenas de ellos. Esto es una violación de la encapsulación: expone la implementación interna que no puede controlar.
\n\nEchemos un vistazo bajo el capó: cómo funciona List<T> internamente
\nPara ente