Ir al contenido
wannacode

Clases anidadas y datos

En resumen: Una clase anidada es una clase dentro de otra porque solo tiene sentido ahí; en JavaScript es un campo estático, y los datos se anidan como objetos y arrays unos dentro de otros.

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 {
constructor(product, quantity, price) {
this.product = product;
this.quantity = quantity;
this.price = price;
}
}
class Order {
lines = [];
}

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. JavaScript no tiene declaración de una clase dentro de otra, pero una clase es un valor y puede ser un campo estático. Así, desde fuera se llama Order.Line, como una clase anidada en Java:

class Order {
static Line = class {
constructor(product, quantity, price) {
this.product = product;
this.quantity = quantity;
this.price = price;
}
total() {
return this.quantity * this.price;
}
};
#lines = [];
add(line) {
this.#lines.push(line);
}
total() {
let sum = 0;
for (const line of this.#lines) {
sum += line.total();
}
return sum;
}
}
const order = new Order();
order.add(new Order.Line("Café en grano", 2, 12.5));
order.add(new Order.Line("Taza", 4, 3.0));
console.log(`Total: ${order.total().toFixed(2)} €`);
Salida
Total: 37.00 €
  • static Line = class { ... } es un campo estático de Order cuyo valor es una clase sin nombre. Desde fuera y dentro de los métodos, es Order.Line.
  • new Order.Line(...) se crea sin un objeto Order, como una clase normal.
  • Order.Line no está ligada a un pedido concreto y no ve sus campos. En Java una clase así se llama estática.

Esto se ve con menos frecuencia. A menudo la clase pequeña simplemente está al lado de la grande, en el mismo archivo, o en lugar de una clase hay un objeto con campos. De eso trata la siguiente tarjeta.

La idea

Datos dentro de datos: objetos y arrays

Si la línea de pedido solo guarda valores y no tiene métodos, en JavaScript a menudo no hay una clase para ella. El pedido es un objeto que guarda un array de objetos:

const order = {
customer: "Fernández",
lines: [
{ product: "Café en grano", quantity: 2, price: 12.5 },
{ product: "Taza", quantity: 4, price: 3.0 },
],
};
let total = 0;
for (const line of order.lines) {
total += line.quantity * line.price;
}
console.log(order.lines[0].product);
console.log(`Total: ${total.toFixed(2)} €`);
Salida
Café en grano
Total: 37.00 €
  • order.lines es el array, order.lines[0] es la primera línea, y order.lines[0].product es el producto que hay en ella. Cada punto o [...] baja un nivel más.
  • Aquí el objeto es para campos conocidos (customer, lines). Para claves que llegan mientras el programa se ejecuta está Map, como en la lección sobre mapas.
  • Una clase es mejor opción cuando los datos tienen comportamiento (un método total) o reglas que hay que cumplir.

Un Map de arrays reúne cosas en grupos:

const people = [["Juan", "Sevilla"], ["María", "Valencia"], ["Ana", "Sevilla"]];
const byCity = new Map();
for (const [name, city] of people) {
if (!byCity.has(city)) {
byCity.set(city, []);
}
byCity.get(city).push(name);
}
console.log(byCity);
console.log(byCity.get("Sevilla")[1]);
Salida
Map(2) { 'Sevilla' => [ 'Juan', 'Ana' ], 'Valencia' => [ 'María' ] }
Ana
Más precisamente, para curiosos
  • Una trampa con los arrays anidados: fill pone una misma referencia en todos los lugares. Las tres filas son un mismo array:
let grid = Array(3).fill([0, 0, 0]);
grid[0][0] = 1;
console.log(grid);
grid = [];
for (let row = 0; row < 3; row++) {
grid.push([0, 0, 0]);
}
grid[0][0] = 1;
console.log(grid);
Salida
[ [ 1, 0, 0 ], [ 1, 0, 0 ], [ 1, 0, 0 ] ]
[ [ 1, 0, 0 ], [ 0, 0, 0 ], [ 0, 0, 0 ] ]
  • console.log muestra solo unos pocos niveles. Más abajo escribe [Array] u [Object]. JSON.stringify muestra el objeto completo:
const shop = { name: "Rincón del Café", menu: { drinks: { hot: ["café", "té"] } } };
console.log(shop);
console.log(JSON.stringify(shop));
Salida
{ name: 'Rincón del Café', menu: { drinks: { hot: [Array] } } }
{"name":"Rincón del Café","menu":{"drinks":{"hot":["café","té"]}}}

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 JavaScript una clase anidada no ve al objeto externo, así que el carrito se pasa de forma explícita:

class Cart {
static Item = class {
constructor(cart, name, price) {
this.cart = cart;
this.name = name;
this.price = price;
}
finalPrice() {
return (this.price * (100 - this.cart.#discountPercent)) / 100;
}
};
#discountPercent;
#items = [];
constructor(discountPercent) {
this.#discountPercent = discountPercent;
}
add(name, price) {
this.#items.push(new Cart.Item(this, name, price));
}
print() {
for (const item of this.#items) {
console.log(`${item.name}: ${item.finalPrice().toFixed(2)} €`);
}
}
}
const 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. JavaScript no tiene algo así, por eso la referencia se ve: add pasa this, e Item la guarda en this.cart. finalPrice lee el #discountPercent del carrito en el que está. Item está escrita dentro de Cart, así que ve los campos # del carrito, igual que una clase interna en Java ve los private.

const 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 this.cart y lee #discountPercent: 80 * (100 - 10) / 100 = 72.
  5. finalPrice de «Funda» sigue la flecha this.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í:
const cart = new Cart(10);
const cable = new Cart.Item(cart, "Cable", 10);
console.log(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?

const regular = new Cart(0);
const 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. Con console.log(new Order.Line("Café", 12.5).show()) el programa se detiene:
Error
ReferenceError: currency is not defined

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

Elige la línea con el error.

La idea

Una clase dentro de una función

Una clase también se puede declarar dentro de una función. Entonces solo se ve ahí y se llama clase local:

function printBestSeller(names, sold) {
class Sale {
constructor(name, count) {
this.name = name;
this.count = count;
}
}
let best = new Sale(names[0], sold[0]);
for (let i = 1; i < names.length; i++) {
if (sold[i] > best.count) {
best = new Sale(names[i], sold[i]);
}
}
console.log(`Más vendido: ${best.name} (${best.count} vendidos)`);
}
const names = ["Café", "Té", "Agua"];
const sold = [120, 45, 300];
printBestSeller(names, sold);
Salida
Más vendido: Agua (300 vendidos)

Sale mantiene juntos el nombre y la cantidad. Nadie la usa fuera de printBestSeller, así que vive ahí. Estas clases son raras, pero conviene conocerlas.

La idea

Un objeto en lugar de una clase

Un descuento de la tienda es algo con un método apply(price). En el módulo «Herencia y polimorfismo» viste que JavaScript no tiene interface: al objeto le basta con tener los métodos. Podrías crear una clase para cada descuento: class BlackFriday { apply(price) { ... } }. En Java, para un descuento que se usa en un solo lugar, existe la clase anónima: una clase sin nombre, escrita justo ahí. En JavaScript normalmente ni siquiera necesitas una clase. Escribes directamente el objeto con el método:

const blackFriday = {
apply(price) {
return (price * 70) / 100;
},
};
const none = {
apply(price) {
return price;
},
};
console.log(blackFriday.apply(100));
console.log(none.apply(100));
Salida
70
100
  • { apply(price) { ... } } es un objeto con un método. No hay clase, y blackFriday.apply(100) funciona igual que con un objeto de una clase.
  • JavaScript imprime 70, no 70.0: el número es entero.
Más precisamente, para curiosos
  • JavaScript sí tiene una clase sin nombre: class { ... } es una expresión y se puede crear al momento. Rara vez se escribe, porque el objeto con un método es más corto:
const none = new (class {
apply(price) {
return price;
}
})();
console.log(none.apply(100));
console.log(none.constructor.name === "");
Salida
100
true
  • Cuando el descuento es una sola acción, en JavaScript a menudo pasas directamente una función, como la función de comparación de la siguiente tarjeta. En el módulo «JavaScript moderno» verás una forma más corta de escribir funciones pequeñas.

La idea

Función de comparación: ordenar con tu propia regla

sort ordenaba texto, y ordenaba números con una función. Con alumnos y sin función, no da error, pero ordena según el texto de toString:

class Student {
constructor(name, grade) {
this.name = name;
this.grade = grade;
}
toString() {
return `${this.name} ${this.grade}`;
}
}
const students = [
new Student("Juan", 5.50),
new Student("María", 6.00),
new Student("Pedro", 4.75),
new Student("Ana", 5.50),
];
students.sort();
console.log(students.join(", "));
Salida
Ana 5.5, Juan 5.5, María 6, Pedro 4.75

Aquí salieron por nombre por casualidad, porque toString empieza con el nombre. Sin toString, cada alumno sería el texto [object Object].

La regla se da como una función de comparación, como con los números en la lección sobre arrays. Recibe dos alumnos a y b y 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:

students.sort(function (a, b) {
return b.grade - a.grade;
});
console.log(students.join(", "));
Salida
María 6, Juan 5.5, Ana 5.5, Pedro 4.75
  • b.grade - a.grade compara al revés, así que la nota más alta va primero.
  • Juan y Ana tienen la misma nota y se quedan en el orden en que estaban. sort no intercambia elementos iguales.
  • join llama a toString de cada alumno.
  • Para texto en orden inverso: sort() y después reverse().
const cities = ["Sevilla", "Valencia", "Bilbao"];
cities.sort();
cities.reverse();
console.log(cities);
Salida
[ 'Valencia', 'Sevilla', 'Bilbao' ]
Más precisamente, para curiosos
  • sort ordena el propio array. Si necesitas uno nuevo, está toSorted con la misma función.
  • b.grade - a.grade es seguro, porque los números en JavaScript no se desbordan como int en Java. Para el texto no hay resta: ahí se usa a.name.localeCompare(b.name) de la lección sobre texto.

Pruébalo

¿Cuál va primero?

¿Qué imprimirá el programa?

const products = [
new Product("Monitor", 220),
new Product("Ratón", 25),
new Product("Teclado", 60),
];
products.sort(function (a, b) {
return a.price - b.price;
});
for (const p of products) {
console.log(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, static Item = class { ... }) y los métodos addItem(item), getTotal() y count(). Agrega new ShoppingCart.Item("Leche", 2.5), pan por 1,20 € y café por 7,50 €. Imprime el total con toFixed(2).

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(r) y printStats(). Las estadísticas dicen cuántas solicitudes hay, cuántas hay de cada código de estado (ordenados como números) y qué porcentaje son correctas, es decir, con un código del 200 al 299. Guarda los conteos en un Map. 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(a) y getApartmentInfo(number). Guarda los apartamentos en un Map de número a apartamento. 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 una sola función de comparación: si las notas son distintas, devuelve b.grade - a.grade; si no, compara los nombres con localeCompare.

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 Map una vez con una clase anidada TripPlanner.Destination que guarde el nombre y el array de actividades, y otra vez solo con un Map de arrays. ¿Cuál es más práctica?
  • La red de contactos, otra vez. Mueve Contact de la tarea de la lección sobre Map dentro de ContactNetwork. ¿Debería Contact conocer su red?
  • Cálculo mental, otra vez. Mueve Question de la tarea de la lección sobre arrays dentro de MathGame.
  • Productos de dos formas. Ordena un array de productos por precio, del más caro, y por nombre cuando el precio es el mismo. Después ordena el mismo array solo por nombre, en orden inverso.
  • Tu propio descuento. Escribe tres objetos con un método apply: un 10 % a partir de 100 €, 5 € fijos de descuento y «el segundo producto a mitad de precio». Aplícalos a un mismo carrito.

Test

Ponte a prueba

1. ¿Cómo haces en JavaScript una clase que desde fuera se llama Order.Line?
2. ¿Ve Order.Line los campos de un pedido concreto?
3. ¿Qué es { apply(price) { return price; } }?
4. La función de comparación de sort devuelve un número negativo para (a, b). ¿Qué significa?
5. students.sort() no tiene función, y Student tiene toString(). ¿Qué pasa?
6. grid = Array(3).fill([0, 0, 0]) y después grid[0][0] = 1. ¿Qué hay en grid?

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