A Set.intersection() sorrendjét a kisebb halmaz dönti el

A Set új halmazműveletei végre kiváltják a kézzel írt filtereket, de az intersection() sorrendjét a kisebb bemenet dönti el. Megnézzük, miért, és hogyan fér bele egy Map, miközben egy Array nem.

A JavaScript Set 2015 óta tudta, hogyan kell egy elemet hozzáadni, törölni és megkeresni. Azt viszont nem tudta, amitől egy halmaz halmaz: uniót, metszetet és különbséget képezni. Erre vagy segédfüggvényt írtunk, vagy egy könyvtárat hívtunk segítségül, mert nyilván egy filter() köré is lehet npm-csomagot építeni.

Az új Set-műveletek ezt rendbe tették. A feltűnő rész mégsem az, hogy végre van intersection(), hanem az, hogy az eredmény sorrendje attól függ, melyik bemeneti halmaz kisebb. Egyetlen, a metszetbe be sem kerülő elem megváltoztathatja a kimenet sorrendjét. Ez nem motorhiba, hanem tudatos specifikációs kompromisszum.

Két halmaz átfedése, a metszet elemeit sárga pontok jelölik

Hét metódus, két fajta eredmény

A TC39 javaslatából hét példánymetódus került az ECMAScriptbe. A union(), intersection(), difference() és symmetricDifference() új Set-et ad vissza; az eredeti halmazokat nem módosítja. Az isSubsetOf(), isSupersetOf() és isDisjointFrom() logikai választ ad.

const enabled = new Set(["search", "export", "billing"]);
const licensed = new Set(["search", "billing"]);

enabled.intersection(licensed); // Set { "search", "billing" }
enabled.difference(licensed);   // Set { "export" }
enabled.isSupersetOf(licensed); // true

A metódusok a modern böngészőkben 2024 közepe óta széles körben elérhetők; az MDN az intersection()-t Baseline 2024 funkcióként jelöli. A cikk példáit Node.js 22.22.3 alatt futtattam.

Ugyanaz a metszet, más sorrend

A Set bejárási sorrendje a beszúrási sorrend, ezért kézenfekvő lenne azt várni, hogy a.intersection(b) mindig az a sorrendjét őrzi. Nem ezt teszi:

const wanted = new Set(["read", "write", "delete"]);

const smallPolicy = new Set(["delete", "read"]);
[...wanted.intersection(smallPolicy)];
// ["delete", "read"] — a kisebb, jobb oldali Set sorrendje

const equalPolicy = new Set(["delete", "read", "archive"]);
[...wanted.intersection(equalPolicy)];
// ["read", "delete"] — azonos méretnél a bal oldal sorrendje

Az archive egyik eredményben sincs benne, mégis átbillentette az algoritmust a másik ágra. Ha a metszetet később tömbbé alakítod, renderelési sorrendként használod vagy snapshot tesztbe teszed, ebből remek „ugyan ki rendezte át?” délután lehet. Ha a sorrend az üzleti logika része, rendezd explicit módon; a halmazművelet tagságot garantál, nem prioritást.

A furcsaságért cserébe nem járjuk be az óriáshalmazt

Az ECMAScript algoritmusa előbb összehasonlítja a méreteket. Ha a fogadó Set a kisebb, annak elemein megy végig, és a másik has() metódusával ellenőriz. Ha a paraméter kisebb, annak kulcsait járja be, és a fogadó halmaz belső adataiban keres. Így a munka a kisebb gyűjtemény méretéhez igazodhat.

A sorrend nem véletlenül lett ilyen. A JavaScriptCore megvalósítása miatt az eredeti, stabil sorrendet előíró algoritmus csak a teljes fogadó halmaz bejárásával tudta volna eldönteni két elem relatív helyét. A TC39 erről szóló vitájának vége az lett, hogy elengedték a stabil bal oldali sorrendet. Magyarul: nem fizettetnek ki egy nagy adatszerkezetet csak azért, hogy a kimenet szebb legyen. Szerintem ez helyes döntés, csak a metódus neve ezt udvariasan elhallgatja.

A Map halmazszerű, az Array nem

A jobb oldali argumentumnak nem muszáj valódi Set-nek lennie. Elég a set-like protokoll: legyen numerikus size tulajdonsága, valamint has() és keys() metódusa. A Map pontosan ilyen, ezért a kulcsai halmazként használhatók:

const roles = new Map([
  ["editor", { label: "Szerkesztő" }],
  ["admin", { label: "Admin" }],
]);

new Set(["admin", "reader"]).intersection(roles);
// Set { "admin" }

A keys() választása szándékos. A Map alap iterátora kulcs–érték párokat ad, miközben a has() kulcsot vizsgál; a kettő együtt következetlen eredményt adna. A tömb viszont nem set-like: nincs size tulajdonsága, a keys() pedig az indexeit szolgáltatja. Ezért az intersection([2, 3]) TypeError, nem pedig csendes, lassú varázslat. A döntés részletes indoklása szerint az iterálható bemenet automatikus Set-té alakítása lerontaná a kisebb halmazra épített teljesítményígéretet. Ha tömböd van, írd ki a költséget: set.intersection(new Set(array)).

Az objektum továbbra sem adatbázisrekord

A kényelmesebb API nem változtatja meg az azonosság szabályait. A Set SameValueZero összehasonlítást használ: a NaN azonos a NaN-nal, a -0 és 0 ugyanaz az elem, az objektumok viszont csak azonos referenciával egyeznek. Két külön létrehozott { id: 1 } objektum metszete üres. Ha rekordokat akarsz azonosítani, előbb stabil primitív kulcsokra — például ID-kra — képezd le őket.

Még egy apró csavar: az új műveletek mindig sima Set-et hoznak létre, akkor is, ha a fogadó objektum egy Set-alosztály. Nem használják a sok meglepetést okozó Symbol.species mechanizmust, és egy felülírt Set.prototype.add sem kap szerepet az eredmény felépítésében. Az alosztálynak saját metódust kell adnia, ha a típusát meg akarja őrizni.

Használnám productionben?

Igen, ha a támogatott futtatókörnyezetekben elérhető, vagy van specifikációhű polyfill. Jogosultságok, feature flag-ek, kijelölt azonosítók és cache-kulcsok metszeténél olvashatóbb, mint az ezredik [...a].filter(x => b.has(x)). Nem használnám automatikusan olyan feldolgozási láncban, ahol a sorrend jelentéssel bír, és objektumrekordok deduplikálását sem bíznám rá.

A kompromisszum tiszta: tömör, natív halmazműveletet kapunk, amely a kisebb bemenethez igazítja a munkát, cserébe az intersection() sorrendje nem a bal oldal szerződése. Aki ebből listát csinál, annak a rendezés továbbra is saját feladata. A JavaScript ezúttal legalább nem tesz úgy, mintha az ingyen lenne.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük