{{meta {load_files: ["code/chapter/06_object.js"], zip: "node/html"}}}
{{quote {author: "Barbara Liskov", title: "Programming with Abstract Data Types", chapter: true}
Un tipo de datos abstracto se realiza al escribir un tipo especial de programa [...] que define el tipo en base a las operaciones que puedan ser realizadas en él.
quote}}
{{index "Liskov, Barbara", "abstract data type"}}
{{figure {url: "img/chapter_picture_6.jpg", alt: "Picture of a rabbit with its proto-rabbit", chapter: framed}}}
El Capítulo 4 introdujo los ((objeto))s en JavaScript. En la cultura de la programación, tenemos una cosa llamada ((programación orientada a objetos)), la cual es un conjunto de técnicas que usan objetos (y conceptos relacionados) como el principio central de la organización del programa.
Aunque nadie realmente está de acuerdo con su definición exacta, la programación orientada a objetos ha contribuido al diseño de muchos lenguajes de programación, incluyendo JavaScript. Este capítulo describirá la forma en la que estas ideas pueden ser aplicadas en JavaScript.
{{index encapsulation, isolation, modularity}}
La idea central en la programación orientada a objetos es dividir a los programas en piezas más pequeñas y hacer que cada pieza sea responsable de gestionar su propio estado.
De esta forma, los conocimientos acerca de como funciona una parte del programa pueden mantenerse locales a esa pieza. Alguien trabajando en otra parte del programa no tiene que recordar o ni siquiera tener una idea de ese conocimiento. Cada vez que los detalles locales cambien, solo el código directamente a su alrededor debe ser actualizado.
{{id interface}}
Las diferentes piezas de un programa como tal, interactúan entre sí a través de ((interfaces)), las cuales son conjuntos limitados de funciones y vinculaciones que proporcionan funcionalidades útiles en un nivel más abstracto, ocultando asi su implementación interna.
{{index "public properties", "private properties", "access control"}}
Tales piezas del programa se modelan usando ((objeto))s. Sus interfaces consisten en un conjunto específico de ((método))s y propiedades. Las propiedades que son parte de la interfaz se llaman publicas. Las otras, las cuales no deberian ser tocadas por el código externo , se les llama privadas.
{{index "underscore character"}}
Muchos lenguajes proporcionan una forma de distinguir entre propiedades publicas y privadas, y ademas evitarán que el código externo pueda acceder a las privadas por completo. JavaScript, una vez más tomando el enfoque minimalista, no hace esto. Todavía no, al menos—hay trabajo en camino para agregar esto al lenguaje.
Aunque el lenguaje no tenga esta distinción incorporada,
los programadores de JavaScript estan usando esta idea con éxito .Típicamente,
la interfaz disponible se describe en la documentación o en los comentarios.
También es común poner un carácter de guión bajo (_
) al comienzo de los
nombres de las propiedades para indicar que estas propiedades son privadas.
Separar la interfaz de la implementación es una gran idea. Esto usualmente es llamado ((encapsulación)).
{{id obj_methods}}
{{index "rabbit example", method, property}}
Los métodos no son más que propiedades que tienen valores de función. Este es un método simple:
let conejo = {};
conejo.hablar = function(linea) {
console.log(`El conejo dice '${linea}'`);
};
conejo.hablar("Estoy vivo.");
// → El conejo dice 'Estoy vivo.'
{{index this, "method call"}}
Por lo general, un método debe hacer algo en el objeto con que se llamó.
Cuando una función es llamada como un método—buscada como una propiedad y
llamada inmediatamente, como en objeto.metodo()
—la vinculación llamada this
("este") en su cuerpo apunta automáticamente al objeto en la cual fue llamada.
function hablar(linea) {
console.log(`El conejo ${this.tipo} dice '${linea}'`);
}
let conejoBlanco = {tipo: "blanco", hablar};
let conejoHambriento = {tipo: "hambriento", hablar};
conejoBlanco.hablar("Oh mis orejas y bigotes, " +
"que tarde se esta haciendo!");
// → El conejo blanco dice 'Oh mis orejas y bigotes, que
// tarde se esta haciendo!'
conejoHambriento.hablar("Podria comerme una zanahoria ahora mismo.");
// → El conejo hambriento dice 'Podria comerme una zanahoria ahora mismo.'
{{id call_method}}
{{index "call method"}}
Puedes pensar en this
como un ((parámetro)) extra que es pasado en
una manera diferente. Si quieres pasarlo explícitamente, puedes usar
el método call
("llamar") de una función, que toma el valor de this
como primer argumento y trata a los argumentos adicionales como parámetros
normales.
hablar.call(conejoHambriento, "Burp!");
// → El conejo hambriento dice 'Burp!'
Como cada función tiene su propia vinculación this
, cuyo valor depende de
la forma en como esta se llama, no puedes hacer referencia al this
del
alcance envolvente en una función regular definida con la palabra clave
function
.
{{index this, "arrow function"}}
Las funciones de flecha son diferentes—no crean su propia vinculación this
,
pero pueden ver la vinculaciónthis
del alcance a su alrededor. Por lo tanto,
puedes hacer algo como el siguiente código, que hace referencia a this
desde adentro de una función local:
function normalizar() {
console.log(this.coordinadas.map(n => n / this.length));
}
normalizar.call({coordinadas: [0, 2, 3], length: 5});
// → [0, 0.4, 0.6]
{{index "map method"}}
Si hubieras escrito el argumento para map
usando la palabra clave function
,
el código no funcionaría.
{{id prototypes}}
{{index "toString method"}}
Observa atentamente.
let vacio = {};
console.log(vacio.toString);
// → function toString(){…}
console.log(vacio.toString());
// → [object Object]
{{index magic}}
Saqué una propiedad de un objeto vacío. Magia!
{{index property, object}}
Bueno, en realidad no. Simplemente he estado ocultando información acerca de como funcionan los objetos en JavaScript. En adición a su conjunto de propiedades, la mayoría de los objetos también tienen un ((prototipo)). Un prototipo es otro objeto que se utiliza como una reserva de propiedades alternativa. Cuando un objeto recibe una solicitud por una propiedad que este no tiene, se buscará en su prototipo la propiedad, luego en el prototipo del prototipo y asi sucesivamente.
{{index "Object prototype"}}
Asi que, quién es el ((prototipo)) de ese objeto vacío? Es el gran
prototipo ancestral, la entidad detrás de casi todos los objetos,
Object.prototype
("Objeto.prototipo").
console.log(Object.getPrototypeOf({}) ==
Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null
{{index "getPrototypeOf function"}}
Como puedes adivinar, Object.getPrototypeOf
("Objeto.obtenerPrototipoDe")
retorna el prototipo de un objeto.
{{index "toString method"}}
Las relaciones prototipo de los objetos en JavaScript forman una estructura
en forma de ((árbol)), y en la raíz de esta estructura se encuentra
Object.prototype
. Este proporciona algunos métodos que pueden ser accedidos
por todos los objetos, como toString
, que convierte un objeto en una
representación de tipo string.
{{index inheritance, "Function prototype", "Array prototype", "Object prototype"}}
Muchos objetos no tienen Object.prototype
directamente como su ((prototipo)),
pero en su lugar tienen otro objeto que proporciona un conjunto diferente de
propiedades predeterminadas. Las funciones derivan de Function.prototype
, y
los arrays derivan de Array.prototype
.
console.log(Object.getPrototypeOf(Math.max) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) ==
Array.prototype);
// → true
{{index "Object prototype"}}
Tal prototipo de objeto tendrá en si mismo un prototipo, a menudo Object.prototype
,
por lo que aún proporciona indirectamente métodos como toString
.
{{index "rabbit example", "Object.create function"}}
Puede usar Object.create
para crear un objeto con un ((prototipo)) especifico.
let conejoPrototipo = {
hablar(linea) {
console.log(`El conejo ${this.tipo} dice '${linea}'`);
}
};
let conejoAsesino = Object.create(conejoPrototipo);
conejoAsesino.tipo = "asesino";
conejoAsesino.hablar("SKREEEE!");
// → El conejo asesino dice 'SKREEEE!'
{{index "shared property"}}
Una propiedad como hablar(linea)
en una expresión de objeto es un atajo
para definir un método. Esta crea una propiedad llamada hablar
y le da
una función como su valor.
El conejo "prototipo" actúa como un contenedor para las propiedades que son compartidas por todos los conejos. Un objeto de conejo individual, como el conejo asesino, contiene propiedades que aplican solo a sí mismo—en este caso su tipo—y deriva propiedades compartidas desde su prototipo.
{{id classes}}
{{index "object-oriented programming"}}
El sistema de ((prototipos)) en JavaScript se puede interpretar como un enfoque informal de un concepto orientado a objetos llamado ((clase))es. Una clase define la forma de un tipo de objeto—qué métodos y propiedades tiene este. Tal objeto es llamado una ((instancia)) de la clase.
Los prototipos son útiles para definir propiedades en las cuales todas las
instancias de una clase compartan el mismo valor, como ((método))s.
Las propiedades que difieren por instancia, como la ((propiedad)) tipo
en nuestros conejos, necesitan almacenarse directamente en los objetos mismos.
{{id constructors}}
Entonces, para crear una instancia de una clase dada, debes crear un objeto que derive del prototipo adecuado, pero también debes asegurarte de que, en sí mismo, este objeto tenga las propiedades que las instancias de esta clase se supone que tengan. Esto es lo que una función ((constructora)) hace.
function crearConejo(tipo) {
let conejo = Object.create(conejoPrototipo);
conejo.tipo = tipo;
return conejo;
}
{{index "new operator", this, "return keyword", [object, creation]}}
JavaScript proporciona una manera de hacer que la definición de este tipo de
funciones sea más fácil. Si colocas la palabra clave new
("new") delante de
una llamada de función, la función sera tratada como un constructor. Esto
significa que un objeto con el prototipo adecuado es creado automáticamente,
vinculado a this
en la función, y retornado al final de la función.
{{index "prototype property"}}
El objeto prototipo utilizado al construir objetos se encuentra al tomar
la propiedad prototype
de la función constructora.
{{index "rabbit example"}}
function Conejo(tipo) {
this.tipo = tipo;
}
Conejo.prototype.hablar = function(linea) {
console.log(`El conejo ${this.tipo} dice '${linea}'`);
};
let conejoRaro = new Conejo("raro");
{{index constructor}}
Los constructores (todas las funciones, de hecho) automáticamente obtienen
una propiedad llamada prototype
, que por defecto contiene un objeto simple
y vacío, que deriva de Object.prototype
. Puedes sobrescribirlo con un nuevo
objeto si asi quieres. O puedes agregar propiedades al objeto ya existente,
como lo hace el ejemplo.
{{index capitalization}}
Por convención, los nombres de los constructores tienen la primera letra en mayúscula para que se puedan distinguir fácilmente de otras funciones.
{{index "prototype property", "getPrototypeOf function"}}
Es importante entender la distinción entre la forma en que un prototipo
está asociado con un constructor (a través de su propiedad prototype
)
y la forma en que los objetos tienen un prototipo (que se puede
encontrar con Object.getPrototypeOf
). El prototipo real de un constructor
es Function.prototype
, ya que los constructores son funciones. Su
propiedad prototype
contiene el prototipo utilizado para las
instancias creadas a traves de el.
console.log(Object.getPrototypeOf(Conejo) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf(conejoRaro) ==
Conejo.prototype);
// → true
Entonces, las ((clase))es en JavaScript son funciones ((constructoras)) con una propiedad ((prototipo)). Así es como funcionan, y hasta 2015, esa era la manera en como tenías que escribirlas. Estos días, tenemos una notación menos incómoda.
class Conejo {
constructor(tipo) {
this.tipo = tipo;
}
hablar(linea) {
console.log(`El conejo ${this.tipo} dice '${linea}'`);
}
}
let conejoAsesino = new Conejo("asesino");
let conejoNegro = new Conejo("negro");
{{index "rabbit example"}}
La palabra clave class
("clase") comienza una ((declaración de clase)),
que nos permite definir un constructor y un conjunto de métodos, todo en un
solo lugar. Cualquier número de métodos se pueden escribir dentro de las llaves
de la declaración. El metodo llamado constructor
es tratado de una manera
especial. Este proporciona la función constructora real, que estará vinculada al
nombre Conejo
. Los otros metodos estaran empacados en el prototipo de ese
constructor. Por lo tanto, la declaración de clase anterior es equivalente a la
definición de constructor en la sección anterior. Solo que se ve mejor.
{{index ["class declaration", properties]}}
Actualmente las declaraciones de clase solo permiten que los metodos—propiedades que contengan funciones—puedan ser agregados al ((prototipo)). Esto puede ser algo inconveniente para cuando quieras guardar un valor no-funcional allí. La próxima versión del lenguaje probablemente mejore esto. Por ahora, tú puedes crear tales propiedades al manipular directamente el prototipo después de haber definido la clase.
Al igual que function
, class
se puede usar tanto en posiciones de
declaración como de expresión. Cuando se usa como una expresión, no define una
vinculación, pero solo produce el constructor como un valor. Tienes permitido
omitir el nombre de clase en una expresión de clase.
let objeto = new class { obtenerPalabra() { return "hola"; } };
console.log(objeto.obtenerPalabra());
// → hola
{{index "shared property", overriding}}
Cuando le agregas una ((propiedad)) a un objeto, ya sea que esté presente en el prototipo o no, la propiedad es agregada al objeto en si mismo. Si ya había una propiedad con el mismo nombre en el prototipo, esta propiedad ya no afectará al objeto, ya que ahora está oculta detrás de la propiedad del propio objeto.
Rabbit.prototype.dientes = "pequeños";
console.log(conejoAsesino.dientes);
// → pequeños
conejoAsesino.dientes = "largos, filosos, y sangrientos";
console.log(conejoAsesino.dientes);
// → largos, filosos, y sangrientos
console.log(conejoNegro.dientes);
// → pequeños
console.log(Rabbit.prototype.dientes);
// → pequeños
{{index [prototype, diagram]}}
El siguiente diagrama esboza la situación después de que este código ha sido
ejecutado. Los ((prototipo))s de Conejo
y Object
se encuentran detrás de
conejoAsesino
como una especie de telón de fondo, donde las propiedades
que no se encuentren en el objeto en sí mismo puedan ser buscadas.
{{figure {url: "img/rabbits.svg", alt: "Rabbit object prototype schema",width: "8cm"}}}
{{index "shared property"}}
Sobreescribir propiedades que existen en un prototipo puede ser algo útil que hacer. Como muestra el ejemplo de los dientes de conejo, esto se puede usar para expresar propiedades excepcionales en instancias de una clase más genérica de objetos, dejando que los objetos no-excepcionales tomen un valor estándar desde su prototipo.
{{index "toString method", "Array prototype", "Function prototype"}}
También puedes sobreescribir para darle a los prototipos estándar de función y
array un método diferente toString
al del objeto prototipo básico.
console.log(Array.prototype.toString ==
Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2
{{index "toString method", "join method", "call method"}}
Llamar a toString
en un array da un resultado similar al de una llamada
.join(",")
en él—pone comas entre los valores del array.
Llamar directamente a Object.prototype.toString
con un array produce un
string diferente. Esa función no sabe acerca de los arrays, por lo que
simplemente pone la palabra object y el nombre del tipo entre corchetes.
console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]
{{index "map method"}}
Vimos a la palabra map usada en el capítulo anterior para una operación que transforma una estructura de datos al aplicar una función en sus elementos.
{{index "map (data structure)", "ages example", "data structure"}}
Un mapa (sustantivo) es una estructura de datos que asocia valores (las llaves) con otros valores. Por ejemplo, es posible que desees mapear nombres a edades. Es posible usar objetos para esto.
let edades = {
Boris: 39,
Liang: 22,
Júlia: 62
};
console.log(`Júlia tiene ${edades["Júlia"]}`);
// → Júlia tiene 62
console.log("Se conoce la edad de Jack?", "Jack" in edades);
// → Se conoce la edad de Jack? false
console.log("Se conoce la edad de toString?", "toString" in edades);
// → Se conoce la edad de toString? true
{{index "Object.prototype", "toString method"}}
Aquí, los nombres de las propiedades del objeto son los nombres de las
personas, y los valores de las propiedades sus edades. Pero ciertamente no
incluimos a nadie llamado toString en nuestro mapa. Sin embargo, debido a
que los objetos simples se derivan de Object.prototype
, parece que
la propiedad está ahí.
{{index "Object.create function", prototype}}
Como tal, usar objetos simples como mapas es peligroso. Hay varias
formas posibles de evitar este problema. Primero, es posible crear
objetos sin ningun prototipo. Si pasas null
a Object.create
,
el objeto resultante no se derivará de Object.prototype
y podra ser
usado de forma segura como un mapa.
console.log("toString" in Object.create(null));
// → false
Los nombres de las ((propiedades)) de los objetos deben ser strings. Si necesitas un mapa cuyas claves no puedan ser convertidas fácilmente a strings—como objetos—no puedes usar un objeto como tu mapa.
{{index "Map class"}}
Afortunadamente, JavaScript viene con una clase llamada Map
que esta
escrita para este propósito exacto. Esta almacena un mapeo y permite cualquier
tipo de llaves.
let edades = new Map();
edades.set("Boris", 39);
edades.set("Liang", 22);
edades.set("Júlia", 62);
console.log(`Júlia tiene ${edades.get("Júlia")}`);
// → Júlia tiene 62
console.log("Se conoce la edad de Jack?", edades.has("Jack"));
// → Se conoce la edad de Jack? false
console.log(edades.has("toString"));
// → false
{{index interface, "set method", "get method", "has method", encapsulation}}
Los métodos set
("establecer"), get
("obtener"), y has
("tiene")
son parte de la interfaz del objeto Map
.
Escribir una estructura de datos que pueda actualizarse rápidamente y
buscar en un gran conjunto de valores no es fácil, pero no tenemos que
preocuparnos acerca de eso. Alguien más lo hizo por nosotros, y podemos
utilizar esta simple interfaz para usar su trabajo.
{{index "hasOwnProperty method", "in operator"}}
Si tienes un objeto simple que necesitas tratar como un mapa por alguna
razón, es útil saber que Object.keys
solo retorna las llaves propias del
objeto, no las que estan en el prototipo. Como alternativa al operador in
,
puedes usar el método hasOwnProperty
("tienePropiaPropiedad"), el cual
ignora el prototipo del objeto.
console.log({x: 1}.hasOwnProperty("x"));
// → true
console.log({x: 1}.hasOwnProperty("toString"));
// → false
{{index "toString method", "String function", polymorphism, overriding, "object-oriented programming"}}
Cuando llamas a la función String
(que convierte un valor a un
string) en un objeto, llamará al método toString
en ese
objeto para tratar de crear un string significativo a partir de el. Mencioné que
algunos de los prototipos estándar definen su propia versión de toString
para que puedan crear un string que contenga información más útil que
"[object Object]"
. También puedes hacer eso tú mismo.
Conejo.prototype.toString = function() {
return `un conejo ${this.tipo}`;
};
console.log(String(conejoNegro));
// → un conejo negro
{{index "object-oriented programming"}}
Esta es una instancia simple de una idea poderosa. Cuando un pedazo de código es
escrito para funcionar con objetos que tienen una cierta ((interfaz))—en este
caso, un método toString
—cualquier tipo de objeto que soporte
esta interfaz se puede conectar al código, y simplemente funcionará.
Esta técnica se llama polimorfismo. El código polimórfico puede funcionar con valores de diferentes formas, siempre y cuando soporten la interfaz que este espera.
{{index "for/of loop", "iterator interface"}}
Mencioné en el Capítulo 4 que un ciclo for
/of
puede recorrer varios tipos de estructuras de datos. Este es otro caso
de polimorfismo—tales ciclos esperan que la estructura de datos exponga una
interfaz específica, lo que hacen los arrays y strings. Y también puedes agregar
esta interfaz a tus propios objetos! Pero antes de que podamos hacer eso,
necesitamos saber qué son los símbolos.
Es posible que múltiples interfaces usen el mismo nombre de propiedad
para diferentes cosas. Por ejemplo, podría definir una interfaz en la que
se suponga que el método toString
convierte el objeto a una pieza
de hilo. No sería posible para un objeto ajustarse a
esa interfaz y al uso estándar de toString
.
Esa sería una mala idea, y este problema no es muy común. La mayoria de los programadores de JavaScript simplemente no piensan en eso. Pero los diseñadores del lenguaje, cuyo trabajo es pensar acerca de estas cosas, nos han proporcionado una solución de todos modos.
{{index "Symbol function", property}}
Cuando afirmé que los nombres de propiedad son strings, eso no fue del todo
preciso. Usualmente lo son, pero también pueden ser ((símbolo))s.
Los símbolos son valores creados con la función Symbol
. A diferencia de los
strings, los símbolos recién creados son únicos—no puedes crear el mismo símbolo
dos veces.
let simbolo = Symbol("nombre");
console.log(simbolo == Symbol("nombre"));
// → false
Conejo.prototype[simbolo] = 55;
console.log(conejoNegro[simbolo]);
// → 55
El string que pases a Symbol
es incluido cuando lo conviertas a
string, y puede hacer que sea más fácil reconocer un símbolo cuando, por
ejemplo, lo muestres en la consola. Pero no tiene sentido más allá de
eso—múltiples símbolos pueden tener el mismo nombre.
Al ser únicos y utilizables como nombres de propiedad, los símbolos son adecuados para definir interfaces que pueden vivir pacíficamente junto a otras propiedades, sin importar cuáles sean sus nombres.
const simboloToString = Symbol("toString");
Array.prototype[simboloToString] = function() {
return `${this.length} cm de hilo azul`;
};
console.log([1, 2].toString());
// → 1,2
console.log([1, 2][simboloToString]());
// → 2 cm de hilo azul
Es posible incluir propiedades de símbolos en expresiones de objetos y clases usando ((corchete))s alrededor del nombre de la ((propiedad)). Eso hace que se evalúe el nombre de la propiedad, al igual que la notación de corchetes para acceder propiedades, lo cual nos permite hacer referencia a una vinculación que contiene el símbolo.
let objetoString = {
[simboloToString]() { return "una cuerda de cañamo"; }
};
console.log(objetoString[simboloToString]());
// → una cuerda de cañamo
{{index "iterable interface", "Symbol.iterator symbol", "for/of loop"}}
Se espera que el objeto dado a un ciclo for
/of
sea iterable.
Esto significa que tenga un método llamado con el símbolo Symbol.iterator
(un valor de símbolo definido por el idioma, almacenado como una propiedad
de la función Symbol
).
{{index "iterator interface", "next method"}}
Cuando sea llamado, ese método debe retornar un objeto que proporcione una
segunda interfaz, iteradora. Esta es la cosa real que realiza la iteración.
Tiene un método next
("siguiente") que retorna el siguiente resultado.
Ese resultado debería ser un objeto con una propiedad value
("valor"),
que proporciona el siguiente valor, si hay uno, y una propiedad done
("listo")
que debería ser cierta cuando no haya más resultados y falso de lo contrario.
Ten en cuenta que los nombres de las propiedades next
, value
y done
son
simples strings, no símbolos. Solo Symbol.iterator
, que probablemente sea
agregado a un monton de objetos diferentes, es un símbolo real.
Podemos usar directamente esta interfaz nosotros mismos.
let iteradorOK = "OK"[Symbol.iterator]();
console.log(iteradorOK.next());
// → {value: "O", done: false}
console.log(iteradorOK.next());
// → {value: "K", done: false}
console.log(iteradorOK.next());
// → {value: undefined, done: true}
{{index "matrix example", "Matrix class", array}}
{{id matrix}}
Implementemos una estructura de datos iterable. Construiremos una clase matriz, que actuara como un array bidimensional.
class Matriz {
constructor(ancho, altura, elemento = (x, y) => undefined) {
this.ancho = ancho;
this.altura = altura;
this.contenido = [];
for (let y = 0; y < altura; y++) {
for (let x = 0; x < ancho; x++) {
this.contenido[y * ancho + x] = elemento(x, y);
}
}
}
obtener(x, y) {
return this.contenido[y * this.ancho + x];
}
establecer(x, y, valor) {
this.contenido[y * this.ancho + x] = valor;
}
}
La clase almacena su contenido en un único array de elementos altura × ancho. Los elementos se almacenan fila por fila, por lo que, por ejemplo, el tercer elemento en la quinta fila es (utilizando indexación basada en cero) almacenado en la posición 4 × ancho + 2.
La función constructora toma un ancho, una altura y una función opcional
de contenido que se usará para llenar los valores iniciales.
Hay métodos obtener
y establecer
para recuperar y actualizar elementos en
la matriz.
Al hacer un ciclo sobre una matriz, generalmente estás interesado en la posición
tanto de los elementos como de los elementos en sí mismos, así que haremos que
nuestro iterador produzca objetos con propiedades x
, y
, y value
("valor").
{{index "MatrixIterator class"}}
class IteradorMatriz {
constructor(matriz) {
this.x = 0;
this.y = 0;
this.matriz = matriz;
}
next() {
if (this.y == this.matriz.altura) return {done: true};
let value = {x: this.x,
y: this.y,
value: this.matriz.obtener(this.x, this.y)};
this.x++;
if (this.x == this.matriz.ancho) {
this.x = 0;
this.y++;
}
return {value, done: false};
}
}
La clase hace un seguimiento del progreso de iterar sobre una matriz en sus
propiedades x
y y
. El método next
("siguiente") comienza comprobando si
la parte inferior de la matriz ha sido alcanzada. Si no es así, primero
crea el objeto que contiene el valor actual y luego actualiza su
posición, moviéndose a la siguiente fila si es necesario.
Configuremos la clase Matriz
para que sea iterable. A lo largo de este libro,
Ocasionalmente usaré la manipulación del prototipo después de los hechos para
agregar métodos a clases, para que las piezas individuales de código
permanezcan pequeñas y autónomas. En un programa regular, donde no hay necesidad
de dividir el código en pedazos pequeños, declararias estos métodos directamente
en la clase.
Matriz.prototype[Symbol.iterator] = function() {
return new IteradorMatriz(this);
};
{{index "for/of loop"}}
Ahora podemos recorrer una matriz con for
/of
.
let matriz = new Matriz(2, 2, (x, y) => `valor ${x},${y}`);
for (let {x, y, value} of matriz) {
console.log(x, y, value);
}
// → 0 0 valor 0,0
// → 1 0 valor 1,0
// → 0 1 valor 0,1
// → 1 1 valor 1,1
{{index interface, property, "Map class"}}
A menudo, las interfaces consisten principalmente de métodos, pero también
está bien incluir propiedades que contengan valores que no sean de función.
Por ejemplo, los objetos Map
tienen una propiedad size
("tamaño")
que te dice cuántas claves hay almacenanadas en ellos.
Ni siquiera es necesario que dicho objeto calcule y almacene tales
propiedades directamente en la instancia. Incluso las propiedades que
pueden ser accedidas directamente pueden ocultar una llamada a un método.
Tales métodos se llaman ((getter))s, y se definen escribiendo get
("obtener")
delante del nombre del método en una expresión de objeto o declaración
de clase.
let tamañoCambiante = {
get tamaño() {
return Math.floor(Math.random() * 100);
}
};
console.log(tamañoCambiante.tamaño);
// → 73
console.log(tamañoCambiante.tamaño);
// → 49
{{index "temperature example"}}
Cuando alguien lee desde la propiedad tamaño
de este objeto, el
método asociado es llamado. Puedes hacer algo similar cuando se escribe
en una propiedad, usando un ((setter)).
class Temperatura {
constructor(celsius) {
this.celsius = celsius;
}
get fahrenheit() {
return this.celsius * 1.8 + 32;
}
set fahrenheit(valor) {
this.celsius = (valor - 32) / 1.8;
}
static desdeFahrenheit(valor) {
return new Temperatura((valor - 32) / 1.8);
}
}
let temp = new Temperatura(22);
console.log(temp.fahrenheit);
// → 71.6
temp.fahrenheit = 86;
console.log(temp.celsius);
// → 30
La clase Temperatura
te permite leer y escribir la temperatura ya sea
en grados ((Celsius)) o grados ((Fahrenheit)), pero
internamente solo almacena Celsius y convierte automáticamente a Celsius
en el getter y setter fahrenheit
.
{{index "static method"}}
Algunas veces quieres adjuntar algunas propiedades directamente a tu función constructora, en lugar de al prototipo. Tales métodos no tienen acceso a una instancia de clase, pero pueden, por ejemplo, ser utilizados para proporcionar formas adicionales de crear instancias.
Dentro de una declaración de clase, métodos que tienen static
("estatico")
escrito antes su nombre son almacenados en el constructor. Entonces, la clase
Temperatura
te permite escribir Temperature.desdeFahrenheit(100)
para
crear una temperatura usando grados Fahrenheit.
{{index inheritance, "matrix example", "object-oriented programming", "SymmetricMatrix class"}}
Algunas matrices son conocidas por ser simétricas. Si duplicas una matriz simétrico alrededor de su diagonal de arriba-izquierda a derecha-abajo, esta se mantiene igual. En otras palabras, el valor almacenado en x,y es siempre el mismo al de y,x.
Imagina que necesitamos una estructura de datos como Matriz
pero que haga
cumplir el hecho de que la matriz es y siga siendo simétrica. Podríamos
escribirla desde cero, pero eso implicaría repetir algo de código muy similar
al que ya hemos escrito.
{{index overriding, prototype}}
El sistema de prototipos en JavaScript hace posible crear una nueva
clase, parecida a la clase anterior, pero con nuevas definiciones para
algunas de sus propiedades. El prototipo de la nueva clase deriva del antiguo
prototipo, pero agrega una nueva definición para, por ejemplo, el método set
.
En términos de programación orientada a objetos, esto se llama ((herencia)). La nueva clase hereda propiedades y comportamientos de la vieja clase.
class MatrizSimetrica extends Matriz {
constructor(tamaño, elemento = (x, y) => undefined) {
super(tamaño, tamaño, (x, y) => {
if (x < y) return elemento(y, x);
else return elemento(x, y);
});
}
set(x, y, valor) {
super.set(x, y, valor);
if (x != y) {
super.set(y, x, valor);
}
}
}
let matriz = new MatrizSimetrica(5, (x, y) => `${x},${y}`);
console.log(matriz.get(2, 3));
// → 3,2
El uso de la palabra extends
indica que esta clase no debe estar
basada directamente en el prototipo de Objeto
predeterminado, pero de
alguna otra clase. Esta se llama la ((superclase)). La clase derivada es la
((subclase)).
Para inicializar una instancia de MatrizSimetrica
, el constructor llama a su
constructor de superclase a través de la palabra clave super
. Esto es necesario
porque si este nuevo objeto se comporta (más o menos) como una Matriz
,
va a necesitar las propiedades de instancia que tienen las matrices. En orden
para asegurar que la matriz sea simétrica, el constructor ajusta el
método contenido
para intercambiar las coordenadas de los valores por debajo del
diagonal.
El método set
nuevamente usa super
, pero esta vez no para llamar al
constructor, pero para llamar a un método específico del conjunto de metodos
de la superclase. Estamos redefiniendo set
pero queremos usar el comportamiento
original. Ya que this.set
se refiere al nuevo método set
, llamarlo
no funcionaria. Dentro de los métodos de clase, super
proporciona una forma de
llamar a los métodos tal y como se definieron en la superclase.
La herencia nos permite construir tipos de datos ligeramente diferentes a partir de tipos de datos existentes con relativamente poco trabajo. Es una parte fundamental de la tradición orientada a objetos, junto con la encapsulación y el polimorfismo. Pero mientras que los últimos dos son considerados como ideas maravillosas en la actualidad, la herencia es más controversial.
{{index complexity, reuse, "class hierarchy"}}
Mientras que la ((encapsulación)) y el polimorfismo se pueden usar para separar piezas de código entre sí, reduciendo el enredo del programa en general, la ((herencia)) fundamentalmente vincula las clases, creando mas enredo. Al heredar de una clase, generalmente tienes que saber más sobre cómo funciona que cuando simplemente la usas. La herencia puede ser una herramienta útil, y la uso de vez en cuando en mis propios programas, pero no debería ser la primera herramienta que busques, y probablemente no deberías estar buscando oportunidades para construir jerarquías (árboles genealógicos de clases) de clases en una manera activa.
{{index type, "instanceof operator", constructor, object}}
Ocasionalmente es útil saber si un objeto fue derivado de una
clase específica. Para esto, JavaScript proporciona un operador binario llamado
instanceof
("instancia de").
console.log(
new MatrizSimetrica(2) instanceof MatrizSimetrica);
// → true
console.log(new MatrizSimetrica(2) instanceof Matriz);
// → true
console.log(new Matriz(2, 2) instanceof MatrizSimetrica);
// → false
console.log([1] instanceof Array);
// → true
{{index inheritance}}
El operador verá a través de los tipos heredados, por lo que una MatrizSimetrica
es una instancia de Matriz
. El operador también se puede aplicar a
constructores estándar como Array
. Casi todos los objetos son una instancia
de Object
.
Entonces los objetos hacen más que solo tener sus propias propiedades. Ellos
tienen prototipos, que son otros objetos. Estos actuarán como si tuvieran
propiedades que no tienen mientras su prototipo tenga esa
propiedad. Los objetos simples tienen Object.prototype
como su prototipo.
Los constructores, que son funciones cuyos nombres generalmente comienzan con
una mayúscula, se pueden usar con el operador new
para crear nuevos
objetos. El prototipo del nuevo objeto será el objeto encontrado en la
propiedad prototype
del constructor. Puedes hacer un buen uso de esto
al poner las propiedades que todos los valores de un tipo dado comparten en
su prototipo. Hay una notación de class
que proporciona una manera clara
de definir un constructor y su prototipo.
Puedes definir getters y setters para secretamente llamar a métodos cada vez que se acceda a la propiedad de un objeto. Los métodos estáticos son métodos almacenados en el constructor de clase, en lugar de su prototipo.
El operador instanceof
puede, dado un objeto y un constructor, decir
si ese objeto es una instancia de ese constructor.
Una cosa útil que hacer con los objetos es especificar una interfaz para ellos y decirle a todos que se supone que deben hablar con ese objeto solo a través de esa interfaz. El resto de los detalles que componen tu objeto ahora estan encapsulados, escondidos detrás de la interfaz.
Más de un tipo puede implementar la misma interfaz. El código escrito para utilizar una interfaz automáticamente sabe cómo trabajar con cualquier cantidad de objetos diferentes que proporcionen la interfaz. Esto se llama polimorfismo.
Al implementar múltiples clases que difieran solo en algunos detalles, puede ser útil escribir las nuevas clases como subclases de una clase existente, heredando parte de su comportamiento.
{{id exercise_vector}}
{{index dimensions, "Vec class", coordinates, "vector (exercise)"}}
Escribe una ((clase)) Vec
que represente un vector en un espacio
de dos dimensiones. Toma los parámetros (numericos) x
y y
, que debería
guardar como propiedades del mismo nombre.
{{index addition, subtraction}}
Dale al prototipo de Vector
dos métodos, mas
y menos
, los cuales toman
otro vector como parámetro y retornan un nuevo vector que tiene la suma
o diferencia de los valores x y y de los dos vectores (this
y el
parámetro).
Agrega una propiedad ((getter)) llamada longitud
al prototipo que calcule la
longitud del vector—es decir, la distancia del punto (x, y) desde
el origen (0, 0).
{{if interactive
// Your code here.
console.log(new Vector(1, 2).mas(new Vector(2, 3)));
// → Vector{x: 3, y: 5}
console.log(new Vector(1, 2).menos(new Vector(2, 3)));
// → Vector{x: -1, y: -1}
console.log(new Vector(3, 4).longitud);
// → 5
if}}
{{hint
{{index "vector (exercise)"}}
Mira de nuevo al ejemplo de la clase Conejo
si no recuerdas muy bien
como se ven las declaraciones de clases.
{{index Pythagoras, "defineProperty function", "square root", "Math.sqrt function"}}
Agregar una propiedad getter al constructor se puede hacer al poner la
palabra get
antes del nombre del método. Para calcular la distancia desde
(0, 0) a (x, y), puedes usar el teorema de Pitágoras, que dice que el
cuadrado de la distancia que estamos buscando es igual al cuadrado de
la coordenada x más el cuadrado de la coordenada y. Por lo tanto, [√(x^2^ +
y^2^)]{if html}[[$\sqrt{x^2 + y^2}$]{latex}]{if tex}
es el número que quieres, y Math.sqrt
es la forma en que calculas una
raíz cuadrada en JavaScript.
hint}}
{{index "groups (exercise)", "Set class", "Group class", "set (data structure)"}}
{{id groups}}
El entorno de JavaScript estándar proporciona otra estructura de datos
llamada Set
("Conjunto"). Al igual que una instancia de Map
,
un conjunto contiene una colección de valores. Pero a diferencia de Map
,
este no asocia valores con otros—este solo rastrea qué valores son parte del
conjunto. Un valor solo puede ser parte de un conjunto una vez—agregarlo de
nuevo no tiene ningún efecto.
{{index "add method", "delete method", "has method"}}
Escribe una clase llamada Conjunto
. Como Set
, debe tener los métodos
add
("añadir"), delete
("eliminar"), y has
("tiene"). Su constructor
crea un conjunto vacío, añadir
agrega un valor al conjunto (pero solo si no
es ya un miembro), eliminar
elimina su argumento del
conjunto (si era un miembro) y tiene
retorna un valor booleano
que indica si su argumento es un miembro del conjunto.
{{index "=== operator", "indexOf method"}}
Usa el operador ===
, o algo equivalente como indexOf
, para
determinar si dos valores son iguales.
{{index "static method"}}
Proporcionale a la clase un método estático desde
que tome un objeto iterable
como argumento y cree un grupo que contenga todos los valores producidos
al iterar sobre el.
{{if interactive
class Conjunto {
// Tu código aquí.
}
let conjunto = Conjunto.desde([10, 20]);
console.log(conjunto.tiene(10));
// → true
console.log(conjunto.tiene(30));
// → false
conjunto.añadir(10);
conjunto.eliminar(10);
console.log(conjunto.tiene(10));
// → false
if}}
{{hint
{{index "groups (exercise)", "Group class", "indexOf method", "includes method"}}
La forma más fácil de hacer esto es almacenar un ((array)) con los miembros del
conjunto en una propiedad de instancia. Los métodos includes
o indexOf
pueden ser usados para verificar si un valor dado está en el array.
{{index "push method"}}
El ((constructor)) de clase puede establecer la colección de miembros como
un array vacio. Cuando se llama a añadir
, debes verificar si el valor dado
esta en el conjunto y agregarlo, por ejemplo con push
, de lo contrario.
{{index "filter method"}}
Eliminar un elemento de un array, en eliminar
, es menos
sencillo, pero puedes usar filter
para crear un nuevo array
sin el valor. No te olvides de sobrescribir la propiedad que
sostiene los miembros del conjunto con la versión recién filtrada del array.
{{index "for/of loop", "iterable interface"}}
El método desde
puede usar un bucle for
/of
para obtener los valores de
el objeto iterable y llamar a añadir
para ponerlos en un conjunto recien
creado.
hint}}
{{index "groups (exercise)", interface, "iterator interface", "Group class"}}
{{id group_iterator}}
Haz iterable la clase Conjunto
del ejercicio anterior. Puedes remitirte
a la sección acerca de la interfaz del iterador anteriormente en el capítulo si
ya no recuerdas muy bien la forma exacta de la interfaz.
Si usaste un array para representar a los miembros del conjunto, no solo
retornes el iterador creado llamando al método Symbol.iterator
en
el array. Eso funcionaría, pero frustra el propósito de este ejercicio.
Está bien si tu iterador se comporta de manera extraña cuando el conjunto es modificado durante la iteración.
{{if interactive
// Tu código aquí (y el codigo del ejercicio anterior)
for (let valor of Conjunto.desde(["a", "b", "c"])) {
console.log(valor);
}
// → a
// → b
// → c
if}}
{{hint
{{index "groups (exercise)", "Group class", "next method"}}
Probablemente valga la pena definir una nueva clase IteradorConjunto
.
Las instancias de Iterador deberian tener una propiedad que rastree la
posición actual en el conjunto. Cada vez que se invoque a next
, este
comprueba si está hecho, y si no, se mueve más allá del valor actual y
lo retorna.
La clase Conjunto
recibe un método llamado por Symbol.iterator
que, cuando se llama, retorna una nueva instancia de la clase de iterador para
ese grupo.
hint}}
Anteriormente en el capítulo mencioné que el metodo hasOwnProperty
de un
objeto puede usarse como una alternativa más robusta al operador in
cuando
quieras ignorar las propiedades del prototipo. Pero, ¿y si tu mapa necesita
incluir la palabra "hasOwnProperty"
? Ya no podrás llamar a ese
método ya que la propiedad del objeto oculta el valor del método.
¿Puedes pensar en una forma de llamar hasOwnProperty
en un objeto que tiene
una propia propiedad con ese nombre?
{{if interactive
let mapa = {uno: true, dos: true, hasOwnProperty: true};
// Arregla esta llamada
console.log(mapa.hasOwnProperty("uno"));
// → true
if}}
{{hint
Recuerda que los métodos que existen en objetos simples provienen de
Object.prototype
.
Y que puedes llamar a una función con una vinculación this
específica al
usar su método call
.
hint}}