Pozor na funkci array_unique(), unikátní je někdy relativní pojem

Byl jednou jeden projekt postavený na Symfony a v něm dlouhá léta spokojeně bydlela funkce array_unique(). Jednoho deštivého dne se však projevil zlý, ošklivý, nepěkný bug.

V jednom celkem starém projektu se nacházel řádek nikoliv nepodobný tomuto:

$uniqueCategories = array_unique($categories);

Autor se potřeboval dobrat od pole kategorií (entit), ve kterém byly opakující se prvky, k poli unikátních prvků. A ono to fakt fungovalo, dokonce několik let. Najednou však výsledek přestal odpovídat očekávání.

Dokumentace praví:

array_unique ( array $array [, int $sort_flags = SORT_STRING ] ) : array

Nepovinný parametr má tedy jako defaultní hodnotu konstantu SORT_STRING, což samozřejmě znamená, že k určení duplicit se používá porovnávání stringových reprezentací prvků. Zde leží jádro bugu a ostatně i toho, že se tak dlouho neprojevil. Metoda __toString() třídy Category celkem standardně vracela název a v e-shopu s pár kategoriemi (z nichž některé byly podkategoriemi) se žádné dvě nejmenovaly stejně. Když ale najednou některé kategorie začaly mít podkategorie se stejnými názvy, zákazníkům se ty "duplicitní" nezobrazovaly, čímž se bug projevil.

Zdánlivé řešení

Z dokumentace lze dále vyčíst, že použitím konstanty SORT_REGULAR jako nepovinného parametru by mohlo být dosaženo žádoucího výsledku. Lze také dohledat, že pro porovnávání se v tomto případě používá ==, což by k vyloučení skutečných duplicit a zachování unikátních entit mohlo stačit. A ono to vskutku na poli objektů podobných entitám funguje. Jenže to zkrátka nefungovalo na poli skutečných entit. Některé duplicity sice byly vyloučeny, jiné ale ne.

Za vysvětlení rozhodně nedám ruku do ohně, ale problém by mohl být v lazy loadingu u entit, které mají nějaké vazby - pak by se tatáž entita nemusela v různých momentech rovnat sama sobě.

Skutečné řešení

Asi netřeba zdůrazňovat, že řešení dost záleží na kontextu. Teoreticky by bylo možné se vrátit k porovnávání stringů a překopat metodu __toString(), což by ale u většiny projektů způsobilo nejméně jeden problém někde jinde. Nabízí se i zrefaktorovat kód tak, aby se duplicity do pole vůbec nedostaly. Já jsem potřeboval co nejrychleji nahradit onen řádek a zbytkem kódu se nezabývat. I tak by se určitě našlo víc možností. Zvolil jsem tuto:

$uniqueCategories = [];
foreach ($categories as $category) {
    $uniqueCategories[$category->getId()] = $category;
}


Zveřejněno 19. 6. 2020