El objeto Set en Javascript: los nuevos arrays en ES6. Teoría, ejemplos y rendimiento comparado

13 Sep 2016

Introducción

La gran popularidad de Javascript así como su ubicuidad en multitud de soporte y plataformas han contribuido de forma positiva en su propia evolución como lenguaje de programación.

En esta ocasión, vamos a repasar otra de las prometedoras novedades de la nueva especificación ES2015: los Sets de datos. Como su nombre sugiere, esta nueva agrupación complementa a las matrices (arrays) añadiendo diferentes métodos y funcionalidades que expanden sus posibilidades tradicionales.

Ya que durante el desarrollo de aplicaciones exigentes debemos recurrir a bibliotecas de terceros para manejar de forma eficiente las colecciones (sean de la naturaleza que fuere), vamos a revisar este nuevo objeto viendo qué funcionalidades aporta y qué rendimiento ofrece sobre distintos escenarios.

Vamos a ello!

Definición

Como en el caso de los arrays tradicionales, un Set de datos es una colección de valores únicos en Javascript, sean estos de cualquier tipo: primitivas (String, Number, Boolean, Symbol) u objetos (referencias).

Para declarar una nueva instancia del objeto Set, utilizamos el opeador new. Por el momento, no tenemos una notación literal o abreviada.

var fooSet = new Set( [iterable] );

Como parámetro, el constructor solo acepta un objeto de tipo iterable: Array, Map, String, arguments… El propio objeto Set, al ser un iterable, puede utilizarse como argumento en el momento de crear uno nuevo.

var str = 'Hello World',
    arr = [ 'foo', 'bar' ],
    map = new Map();
 
var set1 = new Set( str );
var set2 = new Set( arr );
var set3 = new Set( map );
var set4 = new Set( set1 );
 
console.info( set1, set2, set3, set4 );
// Set {} Set {} Set {} Set {}

Métodos básicos

Para poder trabajar de forma efectiva con una colección, necesitaremos métodos que permitan acceder a sus elementos. Estos serían los básicos:

  • add: añade un elemento al Set
  • delete: elimina un elemento del Set
  • forEach: permite aplicar una función sobre cada elemento del Set (según su orden de insercción)

Add

Para añadir un elemento a nuestro Set, utilizamos el método add:

var mySet = new Set();
 
mySet.add( 1 );
mySet.add( 'foo' );

Además de valores, podemos añadir expresiones a nuestro Set para que sean evaluadas en tiempo de ejecución:

mySet.add( 4 * 2 ); // 8
 
var fn = () => 'Hello World';
 
mySet.add( fn() ); // Hello World

También podemos encadenar los métodos consiguiendo un flujo más funcional:

var mySet = new Set().add( 'foo' ).add( 'bar' );
 
console.info( mySet.size ); // 2

Si añadimos un valor dos veces, éste se ignora:

var mySet = new Set( [ 'foo', 'foo', 'bar' ] );
console.info( mySet.size ); // 2

La igualdad de valores se realiza mediante una comparación interna de tipo estricto (===), lo que significa que ésta no aplica a objetos (los cuales, por definición, son siempre diferentes en Javascript):

var objA = {},
    objB = {};
 
console.info( objA === objB ); // false
 
var mySet = new Set( [ objA, objB ] );
console.info( mySet.size ); // 2

Delete

Para borrar un elemento, utilizamos el método delete. Javascript devuelve un Boolean indicando el resultado de la operación:

mySet.delete( 'foo' ); // true
mySet.delete( 'foobar' ); // false
 
var result = mySet.delete( 'bar' );
 
console.info( result ); // false
console.info( typeof result ); // boolean

Igual que en el caso anterior, también podemos operar con expresiones:

var fn = ( x ) => Math.pow( x, 2 );
 
mySet.delete( fn( 12 ) ); // false (144)

Esta sí resulta una novedad con respecto al objeto Array el cual, como bien sabemos, no incorpora un método nativo para eliminar un elemento.

forEach

Al igual que el objeto Array y otros iterables, el objeto Set permite recorrer sus elementos para aplicar una función (callback) sobre cada valor. El orden utilizado es estrictamente el de inserción:

var mySet = new Set();
 
mySet.add( 1 );
mySet.add( 'foo' );
 
mySet.forEach( ( ele ) => console.info( ele ) );
// 1
// foo

El callback del forEach recoge hasta tres valores: los dos primeros (idénticos) son el elemento sobre el que se itera mientras que el tercero apunta al propio objeto Set:

mySet.forEach( ( ele, n, obj ) => console.info( ele ) );
// 1 1 Set {}
// foo foo Set {}

NOTA: Desconozco porque los dos primeros argumentos referencian al mismo valor. Ya en la especificación, se establece de dicho modo: “the first two arguments are a value contained in the Set. The same value is passed for both arguments.”

La propiedad size

La propiedad size, tal y como su nombre sugiere, permite obtener el número de elementos que componen un conjunto dado. El valor devuelto se corresponde con el instante en que se realiza la consulta:

var mySet = new Set();
 
mySet.add( 1 );
mySet.add( 'foo' );
mySet.add( 'foobar' );
 
console.info( mySet.size ); // 3
 
mySet.delete( 'foo' );
console.info( mySet.size ); // 2

Iteración

Al estar trabajando con objetos iterables, podemos recorrer su contenido utilizando un bucle de tipo for…of:

var mySet = new Set().add( 'foo' ).add( 'bar' ).add( 'foobar' );
 
for ( let ele of mySet ) {
    console.info( ele );
}
 
// foo
// bar
// foobar

Otros métodos

Completando la API de este nuevo objeto tenemos los siguientes métodos:

  • clear: elimina todos los elementos de un conjunto.
  • has: comprueba que un valor, o el resultado de una expresión, se encuentra en el Set. Devuelve un valor true o false.
  • entries: devuelve un objeto de tipo iterable que contiene a su vez un array con los valores de cada elemento del Set.
  • values: devuelve un objeto de tipo iterable con el valor de cada uno de los elementos del Set.
  • keys: idéntico al método values.
var mySet = new Set( [ 'foo', 'bar', 'foobar' ] );
 
// has
console.info( mySet.has( 'bar' ) ); // true
console.info( mySet.has( 'xxx' ) ); // false
 
// entries
for ( let value of mySet.entries() ) {
    console.info( value );
}
 
// values
for ( let value of mySet.values() ) {
    console.info( value );
}
 
// keys
for ( let value of mySet.keys() ) {
    console.info( value );
}
 
// Clearing
mySet.clear();
console.info( mySet.size ); // 0

Límite de elementos

Tal y como ocurre con el objeto Array, el límite de elementos de un conjunto de tipo Set se define en gran medida por el escenario sobre el que se ejecuta el código.

Según la especificación, este límite se corresponde con el máximo valor de un entero en 32 bits, lo que evivale a un 2 elevado a la 32 potencia. Esto se comprueba utilizando la operación abstracta interna ToInt32, quedando como valor máximo posible el 4.294.967.295.

Conversiones

Como se ha comentado más arriba, un array puede convertirse en un objeto Set con tan solo referenciarlo en su constructor:

var myArr = [ 'foo', 'bar', 'foobar' ],
    mySet = new Set( myArr );
 
console.info( mySet.size ); // 3
 
mySet.forEach( x => console.info( x ) );
// foo
// bar
// foobar

Si añadimos el array a un Set ya declarado utilizando el método add, lo que se añade es la referencia al array como objeto, no a sus valores independientes:

var myArr = [ 'foo', 'bar', 'foobar' ],
    mySet = new Set();
 
mySet.add( myArr );
 
console.info( mySet.size ); // 1
mySet.forEach( x => console.info( x ) );
// ["foo", "bar", "foobar"]

Para añadir cada uno de los elementos de un array a un Set ya declarado, se pueden utilizar los métodos nativos de Array como por ejemplo map:

var myArr = [ 'foo', 'bar', 'foobar' ],
    mySet = new Set();
 
myArr.map( i => mySet.add( i ) );
 
console.info( mySet.size ); // 3

La acción contraria, convertir un Set en un array, puede conseguirse de varias formas. Tomemos un Set de ejemplo:

var mySet = new Set();
 
mySet.add( 'foo' );
mySet.add( 'bar' );
mySet.add( 'foobar' );

Y apliquemos distintos métodos de conversión:

Operador de arrastre

var myArr = [ ...mySet ];
 
console.info( myArr );
// ["foo", "bar", "foobar"]
 
console.info( myArr instanceof Array ); // true

Nota: para saber más sobre el operador de propagación/arrastre en Javascript: El Operador de Propagación en Javascript (ECMAScript 6 y polyfill)

Método Array.from

var myArr = Array.from( mySet );
 
console.info( myArr );
// ["foo", "bar", "foobar"]
 
console.info( myArr instanceof Array ); // true

Filtrado y mapeado

A diferencia del objeto Array, Set no dispone de los métodos filter() y map(). Para conseguir esta funcionalidad, debemos realizar una conversión previa, operar, y después reconvertir de nuevo nuestro conjunto:

var mySet = new Set().add( 'foo' ).add( 'bar' ).add( 'foobar' );
 
mySet = new Set( [ ...mySet ].map( ele => ele.toUpperCase() ) );
 
mySet.forEach( ele => console.info( ele ) );
// FOO
// BAR
// FOOBAR
 
mySet = new Set( [ ...mySet ].filter( ele => ele.length < 4 ) );
mySet.forEach( ele => console.info( ele ) );
// FOO
// BAR

Operando entre conjuntos: intersección y diferencia

Las operaciones básicas entre conjuntos (intersección y diferencia) se consiguen a través del anteriormente mencionado método filter. Para ello, debemos realizar esa conversión previa mencionada más arriba:

Intersección

Para obtener los elementos del conjunto A repetidos en el conjunto B:

var setA = new Set( [ 'Monday', 'Tuesday', 'Friday', 'Saturday' ] ),
    setB = new Set( [ 'Tuesday', 'Sunday', 'Wednesday', 'Thursday' ] );
 
var setC = new Set( [ ...setA ].filter( x => setB.has( x ) ) );
console.info( setC.size ); // 1
 
setC.forEach( x => console.info( x ) ); // Tuesday

Diferencia

Para obtener los elementos del conjunto A no presentes en el conjunto B:

var setC = new Set( [ ...setA ].filter( x => ! setB.has( x ) ) );
 
console.info( setC.size ); // 3
 
setC.forEach( x => console.info( x ) );
// Monday
// Friday
// Saturday

Unión

Dado que un objeto Set es un conjunto de valores únicos, la suma de un conjunto A y B resulta de la agregación natural de ambos:

var setC = new Set( [ ...setA, ...setB ] );
 
console.info( setC.size ); // 7
 
setC.forEach( x => console.info( x ) );
// Monday
// Tuesday
// Friday
// Saturday
// Sunday
// Wednesday
// Thursday

Rendimiento

Todo lo visto hasta ahora es interesante, pero al margen de un par de métodos nuevos, no hemos visto una evolución significativa sobre el objeto Array. Es hora de ponerse el mono de trabajo y descender al bajo nivel para ver qué ocurre en el plano del rendimiento.

Conjuntos de partida

Ya que los navegadores han ido optimizando a cada versión sus motores de Javascript mejorando con ello el rendimiento, necesitamos partir de un conjunto lo suficientemente grande como para padecer los incómodos tiempos de latencia entre operaciones. En mí caso, el navegador sobre el que realizaré las pruebas es Firefox (v48) sobre un Windows 10 (64bits).

Vamos a crear una matriz de 500.000 (quinientos mil) elementos para nuestras pruebas. Este valor puede provocarnos un error de desbordamiento según el navegador que utilicemos: los usuarios de Google Chrome deben limitarse a valores más bajos (125.000). Para saber más sobre estas limitaciones se puede consultar el siguiente artículo dentro de este mismo blog: Buscando el límite de argumentos para una función Javascript.

var n = 500000, // Chrome users must test with a lower value. Ej: 125000
    arr;
 
arr = Array.apply( null, Array( n ) ).map( ( x, i ) => i );
 
console.info( arr.length ); // 5000

Midiendo tiempos

Para medir el rendimiento de nuestros códigos en Javascript habíamos utilizado tradicionalmente en este blog el servicio web ‘JSPerf‘. Sin embargo, dado que a fecha de redacción de este artículo el servicio se encuentra offline, vamos a recurrir a la propia consola del navegador.

La estructura será la siguiente:

console.time( 'timeTest' );
/* code goes here... */
console.timeEnd( 'timeTest' );

NOTA/Disclaimer: Dado que los tests se circunscriben a un sistema concreto, cada usuario puede experimentar variaciones dependiendo de su escenario: navegador, sistema operativo, versiones…

Localizando un índice

Comparamos el método has de Set con indexOf de Array:

// Helpers
var checkArr = ( arr, item ) => arr.indexOf( item ) !== -1;
var checkSet = ( set, item ) => set.has( item );
 
// Vars
var set,
    result;
 
console.time( 'timeTest' );
result = checkArr( arr, 123123 );
console.timeEnd( 'timeTest' );
 
set = new Set( arr );
 
console.time( 'timeTest' );
checkSet( set, 123123 );
console.timeEnd( 'timeTest' );

Comparación de tiempos:

Array/indexOf Set/has
0.50ms 0.05ms

Como podemos comprobar, has es 10 veces más rápido que su equivalente indexOf.

Añadiendo un nuevo elemento

Comparamos los métodos add y push del objeto Set y Array respectivamente:

console.time( 'timeTest' );
arr.push( n + 1 );
console.timeEnd( 'timeTest' );
 
set = new Set( arr );
 
console.time( 'timeTest' );
set.add( n + 1 );
console.timeEnd( 'timeTest' );
 
console.info( arr.length ); // 500001
console.info( set.size ); // 500001

Los tiempos:

Array/push Set/add
0.13ms 0.04ms

De nuevo, los tiempos del objeto Set son más rápidos…

Eliminando un elemento

Al borrar elementos, tenemos que tener en cuenta que Array y Set no parten en igualdad de condiciones. Array no disponde de un método nativo, por lo que se hace necesario una función externa para ello tal y como vimos en este artículo: Eliminar un elemento de un array en Javascript.

Dicho lo anterior, comparemos el método nativo remove de Set con el nuestro personalizado para Array:

// Helpers
var deleteFromArr = ( arr, item ) => {
    var i = arr.indexOf( item );
    i !== -1 && arr.splice( i, 1 );
};
 
console.time( 'timeTest' );
deleteFromArr( arr, 123123 );
console.timeEnd( 'timeTest' );
 
set = new Set( arr );
 
console.time( 'timeTest' );
set.delete( 123123 );
console.timeEnd( 'timeTest' );

Los tiempos, previsibles:

Array/deleteFromArr Set/remove
15.55ms 0.04ms

Como cabe esperar, nuestra función personalizada resulta mucho más lenta que la opción nativa. En este caso, la media estima al método remove de Set como unas 400 veces más rápido.

Operaciones entre conjuntos

Vamos a continuación a comprobar el rendimiento entre las operaciones de intersección y diferencia que hemos visto más arriba. Aquí la cosa arranca más pareja ya que no existen métodos nativos para ningua de estas operaciones bajos sus respectivos objetos. En ambos casos cabría esperar un mejor rendimiento sobre Array ya que no se precisa de la conversión previa…

Dado que resultaría tedioso organizar dos conjuntos sobre los que operar muy grandes (y que además tengan algún sentido), he cogido un generador de texto de relleno (LoremIpsum) para convertir algunos párrafos en un array. No son conjuntos tan masivos como los anteriores, pero pueden sernos útiles para medir tiempos:

var strA = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam vitae tempus leo, eget iaculis tortor. Sed mattis aliquet lacinia. Sed placerat nunc mauris, a aliquam arcu vulputate non. Duis lorem enim, pretium ac mollis ac, fermentum sit amet eros. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Etiam est augue, luctus vel turpis sed, eleifend aliquam mauris. In eleifend lorem vitae lectus mattis, at facilisis ante maximus. Quisque suscipit ex at magna tempus vestibulum. Duis consectetur justo dui, nec porttitor mi suscipit id. Praesent ultricies pretium mauris, et dapibus enim aliquam non. Nullam et lacus ornare, venenatis augue sed, hendrerit eros. In hac habitasse platea dictumst. Aliquam sit amet sapien at ante sagittis imperdiet in eget nisl. Aenean in efficitur odio. Duis vel ultrices diam. Phasellus blandit dignissim lacus vel iaculis. Suspendisse dapibus leo tellus, dignissim porttitor purus vestibulum eu. Maecenas lacinia ultricies massa vel imperdiet. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Integer posuere at lorem at tristique. Duis maximus eros ipsum, quis faucibus nulla mattis quis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nunc aliquam laoreet finibus. Vivamus euismod ipsum ac ante vehicula porta. Suspendisse consequat tempor ex ac tempor. Etiam interdum sapien fringilla sagittis tincidunt. Phasellus a semper urna. Phasellus venenatis eros quis mi varius, pretium luctus purus pharetra. Sed tortor turpis, ultrices nec dolor ut, rutrum lacinia arcu. Pellentesque vestibulum, est quis ornare ornare, mauris augue feugiat elit, sit amet bibendum lorem mi a sapien.',
    strB = 'Duis vel ultrices diam. Phasellus blandit dignissim lacus vel iaculis. Suspendisse dapibus leo tellus, dignissim porttitor purus vestibulum eu. Maecenas lacinia ultricies massa vel imperdiet. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Integer posuere at lorem at tristique. Duis maximus eros ipsum, quis faucibus nulla mattis quis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nunc aliquam laoreet finibus. Vivamus euismod ipsum ac ante vehicula porta. Suspendisse consequat tempor ex ac tempor. Etiam interdum sapien fringilla sagittis tincidunt. Phasellus a semper urna. Phasellus venenatis eros quis mi varius, pretium luctus purus pharetra. Sed tortor turpis, ultrices nec dolor ut, rutrum lacinia arcu. Pellentesque vestibulum, est quis ornare ornare, mauris augue feugiat elit, sit amet bibendum lorem mi a sapien.';
 
var arrA = strA.split( ' ' ),
    arrB = strB.split( ' ' ),
    result;
 
var setA = new Set( arrA ),
    setB = new Set( arrB );
 
// Difference
console.time( 'timeTest' );
result = arrA.filter( x => arrB.indexOf( x ) !== -1 );
console.timeEnd( 'timeTest' );
 
console.time( 'timeTest' );
result = new Set( [ ...setA ].filter( x => setB.has( x ) ) );
console.timeEnd( 'timeTest' );
 
// Intersect
console.time( 'timeTest' );
result = arrA.filter( x => arrB.indexOf( x ) !== -1 );
console.timeEnd( 'timeTest' );
 
console.time( 'timeTest' );
result = new Set( [ ...setA ].filter( x => ! setB.has( x ) ) );
console.timeEnd( 'timeTest' );

Los tiempos para estas operaciones:

Diferencia Intersección
Array 0.75ms 0.37ms
Set 0.30ms 0.24ms

En este caso, los mejores tiempos de Set no se justifican tan fácilmente: la conversión a arrays mediante el operador de arrastre debería arrojar tiempos mayores al requerirse un ciclo más de cálculo… Sin embargo, queda patente que el navegador está optimizado para este tipo de cómputos en detrimento de Array.

El acceso a memoria

Un dato interesante que puede desprenderse de las tablas anteriores es que las operaciones (nativas) de Set requieren siempre el mismo tiempo. En mí escenario concreto, 0.04ms. Este tiempo es el del acceso a memoria del motor SpiderMonkey, por lo que las operaciones se pueden entender como instantáneas dentro del flujo de ejecución.

Conclusión

El nuevo objeto Set se define como un conjunto de elementos únicos independiente de si estos son primitivas del lenguaje o referencias. Podemos tratarlo como un complemento efectivo del tradicional Array al incorporar ciertos métodos nativos de los que este carece.

No obstante, no estamos ante un sustituto definitivo: el API Set no incluye muchas operaciones que podemos considerar connaturales a los conjuntos lo que hace necesario que para éstas, haya que realizar una conversión previa. También cabe recordar que los elementos de un conjunto Set carecen de índices accesibles, lo que impide obtener el valor de una determinada posición a diferencia de como ocurre en un array.

Por lo tanto, volvemos a reiterar que estamos ante un complemento de Array, útil en aquellos escenarios donde necesitamos almacenar una colección sobre la que aplicar operaciones básicas de adición, borrado, comprobación e iteración. Para otros usos más complejos, veremos que será necesario recurrir al objeto tradicional, bien mediante una conversión directa de Set, o bien instanciando directamente un array.

Más:

Aún no tenemos debug!
Ningún marciano se ha comunicado.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *