Los objetos en JavaScript

Figura
Figura. Los objetos en JavaScript se relacionan por medio de prototipos.

Conocer JavaScript pasa necesariamente por conocer los objetos y como se relacionan por medio de prototipos. La frase en JavaScript todo son objetos trata de sintetizar el hecho de que JavaScript es un lenguaje de objetos basado en prototipos, donde los objetos heredan de otros objetos y en última instancia del prototipo de Object (Object {}).

En la Figura puede ver esquemáticamente esta relación. Los objetos son creados usando un constructor. Es una función Object(){..} (en azul) que tiene una propiedad prototype que apunta al prototipo Object{}. Sólo las funciones tienen un propiedad prototype que les sirve para crear nuevos objetos (flecha azul de puntos), usando ese prototipo como plantilla para crear un nuevo objeto, como Object {a: 1} en color marrón.

Todos los objetos tienen una propiedad interna [[Prototype]] que apunta al objeto desde el cual se creó. El prototipo de un objeto es accesible con __proto__ o el método getPrototypeOf() como veremos en un tema posterior.

Las funciones también tienen un [[Prototype]], pues se crean a partir de la plantilla función vacía function(){}. A su vez ese prototipo apunta al objeto vacío. Al final de la cadena de prototipos siempre vamos a encontrar el objeto nulo (null).

En el siguiente código puede observar que el [[Prototype]] de un objeto es el mismo al que apunta la propiedad prototype de su constructor:

let obj = {a: 1};
//Su constructor es la función Object()
console.log(obj.constructor); // function Object() { [native code] }
//El [[Prototype]] del objeto...
let proto = Object.getPrototypeOf(obj);
//...es el mismo que el prototype de su constructor
console.log(proto === Object.prototype); // true
    

El prototipo de Object lo veremos representado como Object {}. Pero no debemos confundir un prototipo con un objeto, aunque en el fondo ambos son objetos. En las consolas de navegadores veremos representados indistintamente con Object {} el prototipo de Object y los objetos vacíos, pero no son la misma cosa. Para instanciar un objeto vacío podemos hacer {} o también Object(null). En este caso son objetos que no contienen propiedades y cuyos prototipos apuntarán al prototipo de Object. Y este a su vez apuntará a null:

//El prototipo de Object y objetos vacíos
console.log(Object.prototype); // Object {}
console.log({}); // Object {}
console.log(Object(null)); // Object {}
//El prototipo del prototipo de Object a apunta a null 
console.log(Object.prototype.__proto__); // null
//Mientras que el prototipo de los objetos vacíos
//apuntan al prototipo de Object
console.log({}.__proto__); // Object {}
console.log(Object(null).__proto__); // Object {}
    

JavaScript también tiene tipos primitivos string, number, boolean, undefined y symbol que no son objetos. Observe en este ejemplo que el operador typeof nos devuelve un String con el tipo del valor. El tipo para el valor undefined tiene el mismo nombre "undefined":

//Los tipos primitivos
console.log(typeof 123); // number
console.log(typeof "abc"); // string
console.log(typeof true); // boolean
console.log(typeof undefined); // undefined
console.log(typeof Symbol("x")); // symbol
    

A los tipos primitivos string, number, boolean y symbol se les aplica una envoltura del objeto correspondiente para acceder a los métodos del prototipo:

console.log(123.4567 .toFixed(2)); // 123.46
console.log("abc".toUpperCase()); // ABC
console.log(true.toString()); // true
console.log(Symbol("x").toString()); // Symbol(x)
    
Observe en 123.4567 .toFixed(2) que hay un espacio antes de aplicar el método. Esto es necesario con los números para que no origine un error de sintaxis. Otra forma de solucionarlo sería rodear el número con paréntesis (123.4567).toFixed(2)

El valor null también se puede considerar un tipo primitivo, pero typeof null nos devolverá que es un "object". El resto de tipos serán objetos (object) o funciones (function).

//Objetos
console.log(typeof null); // object
console.log(typeof {a: 1}); // object
console.log(typeof [1, 2]); // object
console.log(typeof new Set([1, 2])); // object
console.log(typeof new Date()); // object
//Funciones
function f(){}
console.log(typeof f); // function
console.log(typeof function(){}); // function
console.log(typeof (()=>{})); // function
    

Y por ahora no hay más tipos de datos en JavaScript. Aunque podrian añadirse en el futuro, como se hizo en el año 2015 (ES6) agregando el nuevo tipo symbol.

Inicialización de objetos con notación literal

Al lnicializar un objeto estamos creando un objeto vacío o con propiedades. Hay diversas formas para hacerlo y que desarrollaremos en este y siguientes apartados. La siguiente lista es un resumen de esas formas:

Podemos inicializar un objeto usando notación literal. Escribimos parejas de claves y valores. Los valores pueden ser tipos primitivos u objetos, así como expresiones que devuelvan alguno de ésos.

let miVar = 2;
function fun(x){
    return x+1;
}
//Inicializando objeto con notación literal
let obj = {a: 1, b: miVar, c: fun(2)};
console.log(obj); // {a: 1, b: 2, c: 3}
    

Las claves o nombres de propiedades son siempre tipos string y por tanto deberían ir con comillas. Pero en ciertos casos pueden omitirse pues JavaScript los convertirá en string cuando escribimos o leemos una propiedad. Especialmente cuando las claves son como un nombre válido de variable podemos omitir las comillas. Básicamente un nombre de variable responde al patrón [a-zA-Z_$]+[\w$]*, aunque actualmente también es posible usar caracteres Unicode. En el siguiente código pi puede ser una clave sin comillas. El número 3.1416 también puede ser una clave. Pero si la clave contiene espacios, guiones u otros caracteres si tienen que ir entrecomillados.

let obj = {
    pi: 3.1416,
    3.1416: "pi",
    abc123: 11,
    _: 66,
    $: 55,
    "a b": 99,
    "-2": 88,
    "%&?": 77
};
    

En caso de duda siempre es posible entrecomillar la clave. Pues en cualquier caso es lo mismo let obj = {a: 1} que let obj = {"a": 1}. Y se recupera lo mismo con obj.a que con obj["a"]. En un apartado del tema sobre Símbolos comenté más cosas acerca de los nombres de propiedades de objetos. Y hablando de símbolos, éstos también pueden ser claves de objetos:

let obj = {[Symbol.for("x")]: 1};
console.log(obj); // Object {Symbol(x): 1}
console.log(obj[Symbol.for("x")]); //
obj[Symbol.for("x")] = 2;
obj[Symbol.for("y")] = 3;
console.log(obj); // Object {Symbol(x): 2, Symbol(y): 3}
    

Para los símbolos necesitamos siempre usar corchetes, tanto para escribir como para leer una propiedad. Recuerde que Symbol.for() crea un símbolo único con alcance global. Así cuando volvemos a asignar el símbolo x no crea uno nuevo sino que sobrescribe el mismo. Los símbolos son adecuados para agregar nuevas propiedades a un objeto y tener la completa seguridad de que no sobrescribiremos una propiedad existente.

Los métodos se nombran como las propiedades. En este código vemos que asignamos una expresión de función anónima a un nombre de propiedad. Por otro lado los métodos accesores get y set nos permiten disponer que los valores de una propiedad sólo estén disponibles sólo cuando esos métodos sean ejecutados. Puede ver más sobre métodos accesores getter y setter en un tema posterior.

let x = 0;
let obj = {
    //Una propiedad y un método "clásico"
    propiedad: 1,
    metodo: function(){
        return this.propiedad + 1;
    },
    //Accesores getter y setter
    get otraPropiedad(){
        return x;
    },
    set otraPropiedad(valor){
        x = valor;
    }
};
console.log(Object.getOwnPropertyNames(obj));
// ["propiedad", "metodo", "otraPropiedad"]
    

Observe en el código anterior las tres propiedades de ese objeto obtenidas con el método getOwnPropertyNames(). Vemos que otraPropiedad es la definida por el accesor getter/setter.

Nombres de propiedades abreviados (shorthand property names)

En ES6 se introducen los nombres de propiedades abreviados (Shorthand property names). Si inicializamos un objeto como {key} sin especificar el valor para esa clave es que previamente se ha declarado una variable con el mismo nombre key, cuyos valor será asignado a esa propiedad. Por ejemplo, en el código siguiente tenemos tres variables inicializadas. Podemos ahorrarnos algo de código usando este shorthand:

let a = 1, b = 2, c = 3;
let obj1 = {a: a, b: b, c: c}
console.log(obj1); // Object {a: 1, b: 2, c: 3}
//con shorthand nos ahorramos algo de código
let obj2 = {a, b, c};
console.log(obj2); // Object {a: 1, b: 2, c: 3}
    

Estos nombres de propiedades abreviados o shorthand junto a otras técnicas de ES6 nos permiten construir funciones robustas donde no nos preocupamos por el orden de los argumentos en las llamadas a la función así como del paso de valores no esperados. Veámos este código:

//destructuring de parámetros y parámetros por defecto
function fun({a=3, b=1} = {}){
    let x = a + b;
    let y = a * b;
    //shorthand de claves
    return {x, y};
}
//destructuring de variables x, y
let {x, y} = fun({b: 5});
console.log(x); // 8
console.log(y); // 15
//no pasar ningún valor a la función
({x, y} = fun());
console.log(x); // 4
console.log(y); // 3
    

Si necesitamos que las variables x, y que vienen en el objeto devuelto de la función tomen otros nombres p, q a la vuelta podemos hacerlo de la forma expuesta en el siguiente código. Observe que se debe envolver en paréntesis la expresión, puesto que una sentencia que empiece con { se entenderá como bloque de código y nos daría un error.

//cambiar x,y por p,q
({x: p, y: q} = fun()); //OBSERVE PARÉNTESIS EXTERIORES
console.log(p); // 4
console.log(q); // 3
    

En primer lugar la función recibe una pareja a, b de argumentos y devuelve otra pareja x, y que luego tratamos en el cuerpo principal por fuera de la función.

Resumimos las tres técnicas de ES6 usadas en el ejemplo:

  1. Los parámetros por defecto y el desestructurado de parámetros se aplica en la declaración de la función. Los parámetros tienen un orden. Si tenemos declarada una función como fun(a, b) al llamarla con fun(1, 2) estamos adjudicando los argumentos (1, 2) a los parámetros (a, b), estrictamente en ese orden izquierda a derecha. Si sólo queremos pasar el segundo argumento podríamos hacer fun(undefined, 2) y detectar en la función que el primero no tiene valor.

    Para evitar esto podríamos recibir los argumentos siempre en un objeto, con lo cual no es necesario que estén ordenados, pues las propiedades de un objeto no necesitan de ningún orden. Usando los parámetros por defecto y el desestructurado de parámetros podemos llamar con menos argumentos de los necesarios. A pesar de recibir un objeto, dentro de la función podemos usar los argumentos recibidos directamente gracias al desestructurado de parámetros.

    Observe en el ejemplo que la llamada es con un sólo argumento fun({b: 5}). Y que dentro de la función usamos directamente las variables a, b aunque hayan venido dentro de un objeto. La declaración de parámetros del ejemplo se puede desglosar en tres partes:

    • function fun({a, b}) es un desestructurado de parámetros de tal forma que ejecutar fun({a:1, b:2}) hará implícitamente las asignaciones a los argumentos a=1 y b=2.
    • function fun({a=3, b=1}) añade además valores por defecto a los parámetros. Así con la llamada fun({b:2}) el valor de la primera variable se tomará por defecto como a=3
    • function fun({a=3, b=1}={}) agrega un valor por defecto de un objeto vacío en el caso de una llamada sin argumentos como fun(). En ese caso se tomarán todos los valores por defecto a=3, b=1.
  2. El shorthand de propiedades se aplica a la devolución de valores múltiples en una función. Como las funciones en JavaScript sólo puede devolver un valor simple, tenemos que envolver el resultado en un objeto como [x, y] o {x: x, y: y}. En ese ejemplo queremos devolver un objeto y usamos el shorthand de nombres de propiedades {x, y}.

  3. El destructuring de objetos nos permite recibir el resultado {x, y} de la función y desestructurarlo en variables con let {x, y} = fun({b: 5}).

Nombres de métodos abreviados (shortand method names)

ES6 también introduce los nombres de métodos abreviados (shortand method names). Hasta ahora veníamos incluyendo métodos con la sintaxis key: function(){..}, como una función anónima:

let obj = {
    a: 2,
    b: 3,
    sumar: function(){
        return this.a + this. b;
    },
    multiplicar: function(){
        return this.a * this.b;
    }
};
console.log(obj.sumar()); // 5
console.log(obj.multiplicar()); // 6
    

Ahora con ES6 podemos abreviar esto poniendo sin más el nombre de la función y omitiendo la palabra reservada function:

let obj = {
    a: 2,
    b: 3,
    sumar(){
        return this.a + this. b;
    },
    multiplicar(){
        return this.a * this.b;
    }
};
console.log(obj.sumar()); // 5
console.log(obj.multiplicar()); // 6
    

Otra cosa que podemos hacer en ES6 es incluir un generador como un método de un objeto. Podríamos usar la sintaxis clásica metodo: function* (){..}. Pero el shorthand nos permite ponerlo como un nombre de método abreviado, anteponiendo * para significar que es una función generadora:

let obj = {
    a: 11,
    b: 22,
    c: 33,
    * getValues(){
        let keys = Object.getOwnPropertyNames(this);
        let index = -1;
        while (index++, index < keys.length){
            let val = this[keys[index]];
            if (typeof val !== "function"){
                yield val;
            }
        }
    }
};
let iterValues = obj.getValues();
console.log([...iterValues]); // [11, 22, 33]
    

En el ejemplo le ponemos un método que genera los valores del objeto que no sean funciones. Vemos que al ejecutar el método generador obtenemos un objeto iterador de valores, iterador que podemos usar como fuente de propagación de Array.

Nombres de propiedades computados

La notación con corchetes también se usa ahora en ES6 para los nombres de propiedades computados. Observe como los nombres de las propiedades del siguiente objeto se crean en el momento de la ejecución del script. Cualquier cosa que devuelva un valor nos podría servir como nombre de propiedad:

function fun(clave){
    return "p" + clave;
}
let obj = {
    ["a" + "b"]: 1,
    [fun("k")]: 2,
    [parseInt(Math.random()*1000, 10)]: 3,
    [new Date().toLocaleString()]: 4
};
console.log(obj); // Object {
//  ab: 1, 
//  pk: 2, 
//  271: 3, 
//  29/9/2016 21:33:42: 4}
    

Observe en el resultado que la consola de los navegadores no entrecomilla las claves, pero siempre debemos entenderlas con comillas. Una clave como 29/9/2016 21:33:42 si fuera usada directamente en notación literal debería ir con comillas, no solo por los espacios sino también por los dos puntos y las barras derechas:

console.log({"29/9/2016 21:33:42": 4});
// Object {29/9/2016 21:33:42: 4}
    

Dentro de los corchetes de un nombre computado podríamos poner prácticamente cualquier cosa. En este ejemplo aparentemente usamos como claves un objeto, un Array y valores null y undefined:

let obj = {
    [{a:1}]: 1,
    [[1,2]]: 2,
    [null]: 3,
    [undefined]: 4
};
console.log(obj); // Object {
//    [object Object]: 1, 
//    1,2: 2, 
//    null: 3, 
//    undefined: 4}
    

Observará que el resultado en la consola se está usando como clave la conversión del objeto con el método toString(). Dígamos que si JavaScript espera ahí un String intentará convertirlo en String y no lanzará nigún error si se consigue la conversión. Aplicado a un objeto siempre obtendremos el String "[object Object]". Mientras que para un Array nos dará los elementos separados por comas. Para los otros valores nos los dará como String "null" y "undefined".

Igualmente podríamos acceder con esas claves. Si cambiamos la clave {a: 1} por {a: 2} sucederá que al aplicar toString() ese otro objeto será también "[object Object]" y volvemos a obtener la misma propiedad. Sin embargo podríamos diferenciar claramente entre la clave [1, 2] y [1, 3] porque el toString() obtenido de ellos es diferente.

//No diferencia entre objetos
console.log(obj[{a: 1}]); // 1
console.log(obj[{a: 2}]); // 1
//Si diferencia entre Arrays
console.log(obj[[1,2]]); // 2
console.log(obj[[1,3]]); // undefined
//Null y undefined son también claves válidas
console.log(obj[null]); // 3
console.log(obj[undefined]); // 4
    

Tenga en cuenta que JSON es más estricto con todo esto. El método str = JSON.stringify(obj) convierte un objeto a un string en formato JSON. En este caso si admite el uso de shorthand y claves computadas en el objeto que vamos a convertir:

let a = 1;
let obj = {
    a,
    ["b" + "c"]: 2
};
let json = JSON.stringify(obj);
console.log(json); // {"a":1,"bc":2}
    

El String JSON debe tener claves siempre con comillas dobles para que obj = JSON.parse(str) pueda recuperarlo en un objeto. Por lo tanto un shorthand o una clave computada en un JSON nos acusará error al parsearlo:

//Shorthand no permitido en JSON.parse
try {
    let a = 1;
    let json = '{a}'
    let obj = JSON.parse(json);
} catch(e){
    console.log(e.message);
    //Unexpected token a in JSON at position 1
}
//Claves computadas no permitidas en JSON.parse
try {
    let json = '{["b" + "c"]: 2}';
    let obj = JSON.parse(json);
} catch(e){
    console.log(e.message);
    //Unexpected token [ in JSON at position 1
}
    

Inicializando objetos con el constructor Object()

El constructor Object(arg) crea un objeto a partir del argumento. Como con algunos constructores built-in, puede invocarse con y sin el operador new. Si no hay argumento o es null o undefined se creará un objeto vacío:

//Objetos vacíos
console.log(new Object()); // Object {}
console.log(Object(null)); // Object {}
console.log(Object(undefined)); // Object {}
    

En otro caso se creará un objeto según el tipo primitivo de datos pasado: Number, String, Boolean y Symbol. Sería como crear objetos con los constructores Number(), String(), Boolean() o Symbol():

console.log(Object(1)); // Number {[[PrimitiveValue]]: 1}
console.log(Object("a")); // String {0: "a", length: 1,
                          // [[PrimitiveValue]]: "a"}
console.log(Object(true)); // Boolean {[[PrimitiveValue]]: true}
console.log(Object(Symbol()); // Symbol {[[PrimitiveValue]]: Symbol()}
    

Si le pasamos como argumento un objeto cualquiera nos lo devolverá tal cual:

console.log(Object({a: 1, b: 2})); // Object {a: 1, b: 2}
console.log(Object([1, 2])); // [1, 2]
console.log(Object(new Set([1, 2]))); // Set {1, 2}
    

Se suele recomendar usar notación literal en lugar de los constructores. Pero aún con notación literal JavaScript estará usando el constructor Object() para crear el nuevo objeto. En este código modificamos el prototipo de Object para incluir un método particular. Observe como está disponible en un objeto creado por el constructor y en otro creado con notación literal:

Object.prototype.miMetodo = function(){return 123};
let obj1 = Object();
console.log(obj1.miMetodo()); // 123
let obj2 = {};
console.log(obj2.miMetodo()); // 123
    

Inicializando objetos con constructores

Podemos crear objetos con los constructores clásicos de JavaScript, pues todas las funciones pueden actuar como constructores de objetos. En este ejemplo creamos un objeto Punto que representa un punto en el plano. Un método incorporado al prototipo nos permitirá calcular la distancia hasta otro punto. Observe como usamos los parámetros por defecto:

function Punto(x=0, y=0){
    this.x = x;
    this.y = y;
}
Punto.prototype.distancia = function(punto={x:0, y:0}){
    return Math.sqrt(Math.pow(this.x-punto.x, 2) + 
                     Math.pow(this.y-punto.y, 2));
}
let p1 = new Punto(1, -1);
console.log(p1); // Punto {x: 1, y: -1}
let p2 = new Punto(2, 3);
console.log(p2); // Punto {x: 2, y: 3}
console.log(p1.distancia()); // 1.4142135623730951  
console.log(p1.distancia(p2)); // 4.123105625617661
    

También podemos crear objetos con las nuevas clases de ES6:

class Punto{
    constructor(x=0, y=0){
        this.x = x;
        this.y = y;
    }
    distancia(punto={x:0, y:0}){
        return Math.sqrt(Math.pow(this.x-punto.x, 2) + 
                         Math.pow(this.y-punto.y, 2));
    }
}
let p1 = new Punto(1, -1);
console.log(p1); // Punto {x: 1, y: -1}
let p2 = new Punto(2, 3);
console.log(p2); // Punto {x: 2, y: 3}
console.log(p1.distancia()); // 1.4142135623730951  
console.log(p1.distancia(p2)); // 4.123105625617661
    

Las nuevas clases de ES6 en el fondo funcionan con la relación entre prototipos de JavaScript. Pero a efectos de sintaxis se asemeja a otros lenguajes como Java por ejemplo. Esta forma de programación es la denominada Programación orientada a objetos (POO). En este sitio ya expuse hace años una serie de temas Objetos en JavaScript y también El calendario con objetos JavaScript. Son temas del año 2010 orientados a la POO. Pero actualmente se plantea que POO no siempre es la mejor estrategia para trabajar con JavaScript.

Otras formas de inicializar un objeto

En los siguientes temas veremos otras formas de crear o modificar un objeto usando métodos de Object. Sólo como introducción anotamos algunos ejemplos en este apartado. Una forma es usando Object.create():

//Crear nuevo objeto con Object.create()
let obj = Object.create({}, {
    a: {value: 1},
    b: {value: 2}
});
console.log(obj); // Object {a: 1, b: 2}
    

La ventaja de Object.create() es que podemos pasarle un prototipo en el primer argumento. El segundo argumento es un objeto descriptor de propiedades, que también se usa en el método Object.defineProperties() y que también sirve para crear nuevos objetos:

//Crear nuevo objeto con Object.defineProperties()
let obj = Object.defineProperties({}, {
    a: {value: 1},
    b: {value: 2}
});
console.log(obj); // Object {a: 1, b: 2}
    

En este caso el primer argumento es un objeto que será modificando agregándose o, en su caso, modificándose con las propiedades pasadas en el segundo argumento.

Métodos genéricos y del prototipo de Object

Los métodos genéricos (o estáticos) son los que posee el constructor Object, como Object.create(). En cambio los métodos del prototipo de Object estarán disponibles en las instancias. Para referirnos a ellos podemos verlos escritos como Object.prototype.toString(). En estos temas pondré estas referencias como {}.toString() para acortar un poco el texto. En el fondo ambas cosas conducen al mismo método, pues el objeto vacío {} es una instancia de Object:

console.log({} instanceof Object); // true
console.log({}.toString() === Object.prototype.toString()); // true
    

En el siguiente ejemplo podrá consultar las propiedades genéricas en el navegador que ahora esté usando, marcando los métodos con un enlace que explicará su funcionamiento:

Ejemplo: Propiedades genéricas de Object

en este navegador
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Las propiedades del prototipo en este navegador son:

Ejemplo: El prototipo de Object

en este navegador
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.
NOTAS:

Los métodos defineGetter, defineSetter, lookupGetter y lookupSetter son declarados obsoletos pues se sustituyen por los nuevos métodos accesores getter y setter.

En Firefox aparecen los métodos del prototipo toSource(), watch() y unwatch(), pero no forman parte de la especificación y no serán tratados en estos temas.

En los siguientes temas trataremos los métodos genéricos y del prototipo agrupándolos según su finalidad: métodos básicos, relacionados con los prototipos, para crear y modificiar propiedades y, finalmente, para iterar por un objeto.