Ir al contenido
wannacode

Clases anidadas

En resumen: Una clase anidada es una clase declarada dentro de otra porque solo tiene sentido ahí; en C# ve los miembros privados de la clase externa, pero no está ligada a un objeto externo.

Problema

Una clase que solo tiene sentido en un lugar

En la lección sobre la lista enlazada, Node era una clase aparte. Cualquiera en el programa puede crear nodos, pero solo tienen sentido en la lista. Lo mismo pasa con una línea de pedido:

class OrderLine
{
public string Product { get; set; }
public int Quantity { get; set; }
public double Price { get; set; }
}
class Order
{
public List<OrderLine> Lines { get; } = new List<OrderLine>();
}

OrderLine sin Order no tiene sentido. El nombre es largo para que no choque con la línea de una factura, InvoiceLine, ni con la línea de un almacén. Y nada dice que las dos clases van juntas. ¿Puede la línea vivir dentro del pedido?

La idea

Clase anidada

Puede. Una clase declarada dentro de otra clase se llama clase anidada:

class Order
{
public class Line
{
public string Product { get; }
public int Quantity { get; }
public double Price { get; }
public Line(string product, int quantity, double price)
{
Product = product;
Quantity = quantity;
Price = price;
}
public double Total()
{
return Quantity * Price;
}
}
private List<Line> lines = new List<Line>();
public void Add(Line line)
{
lines.Add(line);
}
public double Total()
{
double sum = 0;
foreach (Line line in lines)
{
sum += line.Total();
}
return sum;
}
}
var order = new Order();
order.Add(new Order.Line("Café en grano", 2, 12.5));
order.Add(new Order.Line("Taza", 4, 3.0));
Console.WriteLine($"Total: {order.Total():F2} €");
Salida
Total: 37.00 €
  • Desde fuera, el nombre es Order.Line. Dentro de Order, es solo Line.
  • La línea no está ligada a un pedido concreto. new Order.Line(...) se crea sin un objeto Order, como una clase normal.
  • Una clase anidada sin nada delante de class es private: nadie fuera de Order puede usarla. Por eso aquí es public class Line. Sin public, el compilador se detiene en new Order.Line(...):
class Order
{
class Line
{
}
}
Error de compilación
error CS0122: 'Order.Line' is inaccessible due to its protection level

Ya usaste una clase así sin verla: Keys de un Dictionary es un objeto de KeyCollection, una clase anidada en Dictionary:

var stock = new Dictionary<string, int>();
Console.WriteLine(stock.Keys.GetType().Name);
Console.WriteLine(stock.Keys.GetType().DeclaringType.Name);
Salida
KeyCollection
Dictionary`2

Dictionary`2 es el nombre que guarda el entorno de ejecución para Dictionary<TKey, TValue>: 2 es el número de tipos entre < >.

Más precisamente, para curiosos
  • En Java una clase así se escribe static class Line y se llama clase anidada estática. En C#, static delante de una clase significa otra cosa: una clase estática es una clase sin objetos, solo con miembros static, como Math. No puedes usar new con ella:
class Order
{
public static class Line
{
}
}
Error de compilación
error CS0712: Cannot create an instance of the static class 'Order.Line'
  • Toda clase anidada en C# es como la estática en Java. C# no tiene una clase interna ligada a un objeto externo. Lo que se hace en su lugar está en la siguiente tarjeta.

La idea

El objeto anidado guarda al externo

El carrito tiene un descuento para todos los productos que hay en él. Cada producto tiene que conocer el descuento de su propio carrito. En C# una clase anidada no ve al objeto externo, así que el carrito se pasa de forma explícita:

class Cart
{
private int discountPercent;
private List<Item> items = new List<Item>();
public Cart(int discountPercent)
{
this.discountPercent = discountPercent;
}
public class Item
{
private Cart cart;
public string Name { get; }
public double Price { get; }
public Item(Cart cart, string name, double price)
{
this.cart = cart;
Name = name;
Price = price;
}
public double FinalPrice()
{
return Price * (100 - cart.discountPercent) / 100;
}
}
public void Add(string name, double price)
{
items.Add(new Item(this, name, price));
}
public void Print()
{
foreach (Item item in items)
{
Console.WriteLine($"{item.Name}: {item.FinalPrice():F2} €");
}
}
}
var cart = new Cart(10);
cart.Add("Auriculares", 80);
cart.Add("Funda", 20);
cart.Print();
Salida
Auriculares: 72.00 €
Funda: 18.00 €
Java tiene la clase interna: cada objeto suyo guarda una referencia oculta al objeto externo. C# no tiene algo así, por eso la referencia se ve: Add pasa this, e Item la guarda en el campo cart. FinalPrice lee el discountPercent del carrito en el que está. El campo es private, pero Item está escrita dentro de Cart, y una clase anidada ve los miembros private de la externa.

var cart = new Cart(10);
cart.Add("Auriculares", 80);
cart.Add("Funda", 20);
cart.Print();
Paso a paso, en palabras
  1. El carrito es un objeto Cart con un descuento del 10 %.
  2. Add crea un Item y le pasa el carrito, this. Además de Name y Price, cada Item guarda en cart una flecha a su propio carrito.
  3. El segundo Item apunta al mismo carrito.
  4. FinalPrice de «Auriculares» sigue la flecha cart y lee discountPercent: 80 * (100 - 10) / 100 = 72.
  5. FinalPrice de «Funda» sigue la flecha cart y lee discountPercent: 20 * (100 - 10) / 100 = 18.
Más precisamente, para curiosos
  • Desde fuera, un objeto Item se crea con el carrito como primer argumento. Normalmente la clase externa crea las internas ella misma, como add aquí:
var cart = new Cart(10);
var cable = new Cart.Item(cart, "Cable", 10);
Console.WriteLine(cable.FinalPrice());
Salida
9
  • El campo cart es justo lo que hace el Cart.this oculto en Java. Aquí simplemente se ve.
  • Si la clase anidada no usa los campos de la clase externa, no le des el objeto externo. Así no guarda una referencia que no necesita.

Pruébalo

Dos carritos

La clase Cart es la misma. Un carrito no tiene descuento, y el otro tiene un 20 % para los socios del club.

¿Qué imprimirá el programa?

var regular = new Cart(0);
var member = new Cart(20);
regular.Add("Libro", 15);
member.Add("Libro", 15);
regular.Print();
member.Print();
Respuesta

Pruébalo

Encuentra el error

La línea debería mostrar la moneda del pedido. El programa no compila:
Error de compilación
error CS0120: An object reference is required for the non-static field, method, or property 'Order.currency'

¿En qué línea está el problema?

Elige la línea con el error.

La idea

En lugar de una clase dentro de un método: una tupla

En Java una clase también se puede declarar dentro de un método. Entonces solo se ve ahí y se llama clase local. C# no tiene algo así: una clase dentro de un método no compila. Si un método necesita unos cuantos valores que van juntos solo dentro de él, C# tiene la tupla:

static void PrintBestSeller(string[] names, int[] sold)
{
(string Name, int Count) best = (names[0], sold[0]);
for (int i = 1; i < names.Length; i++)
{
if (sold[i] > best.Count)
{
best = (names[i], sold[i]);
}
}
Console.WriteLine($"Más vendido: {best.Name} ({best.Count} vendidos)");
}
string[] names = { "Café", "Té", "Agua" };
int[] sold = { 120, 45, 300 };
PrintBestSeller(names, sold);
Salida
Más vendido: Agua (300 vendidos)
  • (string Name, int Count) es el tipo: dos valores, cada uno con su propio nombre. best.Name y best.Count se leen como propiedades.
  • (names[i], sold[i]) crea una tupla nueva, y se asigna de una vez.
  • Nadie la usa fuera de PrintBestSeller. Si los mismos datos hacen falta en otros lugares o tienen comportamiento, es una clase, como Order.Line.

La idea

No hay clase anónima

Los descuentos de la tienda son una interfaz:

interface IDiscount
{
double Apply(double price);
}

Hasta ahora creabas una clase para cada descuento: class BlackFriday : IDiscount { ... }. Para un descuento que se usa en un solo lugar, Java tiene la clase anónima: una clase sin nombre, escrita justo ahí, new Discount() { ... }. C# no tiene algo así: new IDiscount() { ... } no compila. Una clase siempre tiene nombre. Si solo hace falta en una clase, va anidada y private, como en esta lección:

class Checkout
{
private class BlackFriday : IDiscount
{
public double Apply(double price)
{
return price * 70 / 100;
}
}
private class NoDiscount : IDiscount
{
public double Apply(double price)
{
return price;
}
}
public static IDiscount For(string promo)
{
if (promo == "black friday")
{
return new BlackFriday();
}
return new NoDiscount();
}
}
Console.WriteLine(Checkout.For("black friday").Apply(100));
Console.WriteLine(Checkout.For("ninguno").Apply(100));
Salida
70
100
  • BlackFriday y NoDiscount no se ven fuera de Checkout. Desde fuera, lo único que sabes es que For devuelve un IDiscount.
  • 100 * 70 / 100 con double es 70. C# imprime un double sin .0 cuando el número es entero.
Más precisamente, para curiosos
  • Para una sola acción, como el descuento, C# tiene una forma más corta: un delegado y una lambda. Así no necesitas una clase. Los verás en el módulo «C# moderno».
  • C# sí tiene algo con «anónimo» en el nombre: un tipo anónimo. new { Name = "Agua", Count = 300 } crea un objeto de una clase sin nombre, con propiedades de solo lectura. No tiene métodos y no puede implementar una interfaz, así que no sustituye a la clase anónima:
var sale = new { Name = "Agua", Count = 300 };
Console.WriteLine(sale.Name);
Console.WriteLine(sale);
Salida
Agua
{ Name = Agua, Count = 300 }

La idea

IComparer: ordenar con tu propia regla

Sort ordenaba texto y números. Pero no puede ordenar alumnos:

class Student
{
public string Name { get; }
public double Grade { get; }
public Student(string name, double grade)
{
Name = name;
Grade = grade;
}
public override string ToString()
{
return $"{Name} {Grade}";
}
}
var students = new List<Student>
{
new Student("Juan", 5.50),
new Student("María", 6.00),
new Student("Pedro", 4.75),
new Student("Ana", 5.50)
};
students.Sort();
Error
InvalidOperationException: Failed to compare two elements in the array.

El compilador no protesta, pero al ejecutarlo, el programa se detiene. C# no sabe por qué ordenar a los alumnos: ¿por nombre, por nota, por edad?

La regla se da aparte, como un IComparer. Es una interfaz con un método, Compare(a, b). Devuelve un número negativo si a debe ir antes que b, cero si son iguales y un número positivo si a va después de b. Escribes una clase que la implementa y pasas un objeto de ella a Sort:

class ByGradeDescending : IComparer<Student>
{
public int Compare(Student a, Student b)
{
return b.Grade.CompareTo(a.Grade);
}
}
students.Sort(new ByGradeDescending());
Console.WriteLine(string.Join(", ", students));
Salida
María 6, Juan 5.5, Ana 5.5, Pedro 4.75
  • b.Grade.CompareTo(a.Grade) compara al revés, así que la nota más alta va primero.
  • Juan y Ana tienen la misma nota. Aquí se quedaron en el orden en que estaban, pero List.Sort no lo promete: con más alumnos, los iguales pueden cambiar de lugar. Si el orden importa cuando las notas son iguales, dilo en Compare, como en el ejercicio.
  • string.Join llama a ToString de cada alumno.
  • Para texto y números en orden inverso: Sort() y después Reverse().
var cities = new List<string> { "Sevilla", "Valencia", "Bilbao" };
cities.Sort();
cities.Reverse();
Console.WriteLine(string.Join(", ", cities));
Salida
Valencia, Sevilla, Bilbao
Más precisamente, para curiosos
  • IComparer<T> está en System.Collections.Generic, con un parámetro de tipo: IComparer<Student> compara alumnos.
  • El texto y los números se ordenan sin IComparer, porque string e int implementan la interfaz IComparable<T> con un método CompareTo. Tu clase también puede implementarla, si tiene un orden natural. Entonces Sort() funciona sin nada más:
class City : IComparable<City>
{
public string Name { get; }
public int Population { get; }
public City(string name, int population)
{
Name = name;
Population = population;
}
public int CompareTo(City other)
{
return Population.CompareTo(other.Population);
}
public override string ToString()
{
return Name;
}
}
var cities = new List<City> { new City("Sevilla", 1300000), new City("Toledo", 140000), new City("Valencia", 330000) };
cities.Sort();
Console.WriteLine(string.Join(", ", cities));
Salida
Toledo, Valencia, Sevilla

IComparer es para los casos en que hay varios órdenes.

  • CompareTo te protege de errores. a.Grade - b.Grade ni siquiera compila, porque es un double, y Compare devuelve un int:
class ByGrade : IComparer<Student>
{
public int Compare(Student a, Student b)
{
return a.Grade - b.Grade;
}
}
Error de compilación
error CS0266: Cannot implicitly convert type 'double' to 'int'. An explicit conversion exists (are you missing a cast?)

Con int la resta compila, pero con números muy grandes se desborda y el signo cambia.

  • Una clase entera para una sola comparación es mucho código. En el módulo «C# moderno» la regla se pasará directamente a Sort, en una línea.

Pruébalo

¿Cuál va primero?

¿Qué imprimirá el programa?

class ByPrice : IComparer<Product>
{
public int Compare(Product a, Product b)
{
return a.Price.CompareTo(b.Price);
}
}
var products = new List<Product>
{
new Product("Monitor", 220),
new Product("Ratón", 25),
new Product("Teclado", 60)
};
products.Sort(new ByPrice());
foreach (Product p in products)
{
Console.WriteLine(p.Name);
}
Respuesta

Ejercicio

Clases que van juntas

Resuelve las tareas en tu editor.

1. Carrito de compras. Una clase ShoppingCart con una clase anidada Item (nombre y precio) y los métodos AddItem(Item item), GetTotal() y Count(). En Main, agrega new ShoppingCart.Item("Leche", 2.5), pan por 1,20 € y café por 7,50 €. Imprime el total con :F2.

Salida
Productos: 3
Total: 11.20 €

2. Monitor de un servidor web. Una clase WebServer con una clase anidada Request (dirección y código de estado) y los métodos AddRequest(Request r) y PrintStats(). Las estadísticas dicen cuántas solicitudes hay, cuántas hay de cada código de estado (ordenados) y qué porcentaje son correctas, es decir, con un código del 200 al 299. Guarda los conteos en un SortedDictionary<int, int>: mantiene las claves ordenadas. Las solicitudes son /home 200, /cart 200, /login 401, /home 200, /api/orders 500.

Salida
Solicitudes: 5
200: 3
401: 1
500: 1
Correctas: 60%

3. Administrador de un edificio. Una clase Building con una clase anidada Apartment (número, número de habitaciones, propietario) y los métodos AddApartment(Apartment a) y GetApartmentInfo(int number). Guarda los apartamentos en un Dictionary<int, Apartment>. Agrega el 101, de 3 habitaciones, de Fernández, y el 102, de 2 habitaciones, de Pérez. Busca el 101 y el 999.

Salida
Apartamento 101: 3 habitaciones, propietario: Fernández
No existe el apartamento 999.

4. Clasificación. Ordena a los alumnos de la lección por nota, de la más alta a la más baja. Cuando las notas son iguales, ordénalos por nombre, en orden alfabético. Todo va en un solo IComparer<Student>: si la comparación por nota no es 0, devuélvela; si no, compara los nombres con string.Compare.

Salida
María 6
Ana 5.5
Juan 5.5
Pedro 4.75

Más tareas

  • Configurador de computadoras. Una clase Computer con las clases anidadas Cpu (marca, velocidad en GHz), Ram (GB) y Storage (GB) y los métodos SetCpu, SetRam, SetStorage. ToString describe la configuración: Intel 3.5 GHz, 16 GB RAM, 512 GB.
  • El planificador de viajes, otra vez. Haz la tarea de la lección sobre Dictionary con una clase anidada TripPlanner.Destination que guarde el nombre y la lista de actividades.
  • La red de contactos, otra vez. Mueve Contact de la tarea de la lección sobre Dictionary dentro de ContactNetwork. ¿Debería Contact conocer su red?
  • Cálculo mental, otra vez. Mueve Question de la tarea de la lección sobre listas dentro de MathGame.
  • Productos de dos formas. Ordena una lista de productos por precio, del más caro, y por nombre cuando el precio es el mismo. Después ordena la misma lista solo por nombre, en orden inverso.
  • Tu propio descuento. Escribe tres clases anidadas en Checkout que implementen IDiscount: un 10 % a partir de 100 €, 5 € fijos de descuento y «el segundo producto a mitad de precio». Aplícalas a un mismo carrito.

Test

Ponte a prueba

1. class Item está anidada en class Cart. ¿Ve Item los campos de un carrito concreto?
2. ¿Por qué Show() de Order.Line no compila si lee la moneda del pedido?
3. ¿Qué escribes en C# en lugar de una clase anónima new Discount() { … } de Java?
4. Compare(a, b) devuelve un número negativo. ¿Qué significa eso para List.Sort?
5. ¿Qué escribes en Compare para ordenar a los alumnos por nota, de la más alta a la más baja?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·