PHP: Frankenstein arrays

Gepost in PHP, Development, 3 maanden geleden Leestijd: 8 minuten
image

Een van de core datatypes van PHP is de array. Deze bestaat al min of meer in de zelfde vorm sinds het vroege begin van de taal. De naam "array" is nogal onhandig gekozen, en de implementatie ook, want eigenlijk is het niet echt een array.

Eigenlijk is het een soort van Frankenstein combinatie van een list en een dictionary zoals we die in andere talen kennen. Dat is op zijn zachtst gezegd verwarrend, en kan zorgen voor onverwachte en soms nare effecten. Soms gaan dingen er zelfs door kapot. Zoals ik deze week meemaakte. Hierover later meer.

Maar om te beginnen, wat is het verschil nou tussen lists en dictionaries?

Een list, of array zoals ie ook wel genoemd wordt, is, zoals de naam al zegt, een lijst van elementen. Deze elementen hebben een vaste volgorde, en elk element heeft een opeenvolgende numerieke index, beginnend met 0.

Voorbeeld in Javascript:

let myList = [2, 1, 'foo', new Date()];
let myElem = myList[2]; // myElem contains 'foo'

In Python heet het een dictionary, Perl en Ruby noemen het een hash, en in Javascript en in JSON heet het een object . Wat ook een nogal verwarrende naam is, maar dat is weer een ander verhaal.

Hoe ie ook heet, een dictionary is in de kern een verzameling van key/value pairs. Deze key/value pairs hebben niet per se een vaste volgorde. De keys zijn in de regel strings. En elke key is uniek.

Een voorbeeld in Python:

myDict = {'foo': 'bar', 'boo': 'baz'}
myElem = myDict['foo']  # myElem contains 'bar'
myDict['boo'] = 'bla'
print(myDict)  # output: {'foo': 'bar', 'boo': 'bla'}

Ooit, lang geleden, dachten de makers van PHP dat het een Goed Idee zou zijn om lists en dictionaries samen te voegen tot 1 datatype, dat ze ook nog om de verwarring compleet te maken, de array hebben genoemd. Dat had de volgende gevolgen:

  • elementen in een PHP array hebben altijd een vaste volgorde
  • elementen in een PHP array kunnen een string based key hebben, of een numerieke index
  • deze numerieke indexes kunnen opeenvolgend zijn (spoiler alert: dit is een essentieel punt!)

Dit zijn best veel variabelen, zou dat tot problemen kunnen leiden? Let's see...

$myArray = [
    'element 1',
    'element 2',
    'element 3',
];
print_r($myArray);
print_r($myArray[1]);

geeft als output:

Array
(
    [0] => element 1
    [1] => element 2
    [2] => element 3
)
element 2

Dat ziet er op zich best intuitief uit, en dit werkt eigenlijk net als in andere talen. De elementen krijgen automatisch een numerieke, opeenvolgende index toegewezen, en de elementen zijn benaderbaar met deze index.

Met key/value pairs werkt het op zich ook zoals je zou verwachten:

$myArray = [
    'foo' => 'bar',
    'boo' => 'baz',
];
print_r($myArray);
print_r($myArray['boo']);

Output:

Array
(
    [foo] => bar
    [boo] => baz
)
baz

De specifiek gedefinieerde keys vervangen hier dus de automatisch toegewezen numerieke keys.

Iets verwarrender wordt het als we lists en dictionaries gaan combineren, wat volkomen okay is in PHP:

$myArray = [
    'foo' => 'bar',
    'blarp',
    4 => 'elem with numeric index',
    2 => 'another elem with lower numeric index',
    'boo' => 'baz',
];

// add an element to the end of the array
$myArray[] = 'zonk';
print_r($myArray);
$myArray[3] = 'three';
print_r($myArray);

Dit geeft de volgende output:

Array
(
    [foo] => bar
    [0] => blarp
    [4] => elem with numeric index
    [2] => another elem with lower numeric index
    [boo] => baz
    [5] => zonk
)
Array
(
    [foo] => bar
    [0] => blarp
    [4] => elem with numeric index
    [2] => another elem with lower numeric index
    [boo] => baz
    [5] => zonk
    [3] => three
)
  • als er niet expliciet een key wordt gespecificeerd, wordt er door PHP automatisch een numerieke key toegewezen
  • deze key is de hoogste aanwezige numerieke key + 1, of 0 als er geen zijn
  • de positie van elk element is niet afhankelijk van de numerieke key

Aangezien een PHP array niet altijd opeenvolgende indexes heeft, kun je er dus ook niet altijd op de volgende manier over itereren:

for ($i = 0; $i < count($myArray); $i++) {
    $elem = $myArray[$i];
    // ...
}

Met bovenstaande array zou dat onverwachte resultaten geven, en Undefined array key warnings:

blarp
PHP Warning:  Undefined array key 1 in /home/lennart/Development/php/arrays.php on line 47

another elem with lower numeric index
three
elem with numeric index
zonk
PHP Warning:  Undefined array key 6 in /home/lennart/Development/php/arrays.php on line 47

In plaats van bovenstaande for loop gebruik je beter een foreach loop:

foreach ($myArray as $key => $elem) {
    // ...
}

Want dat werkt wel altijd zoals je verwacht.

Dus, niks aan de hand toch?

Nee. De wereld is echter veel groter dan alleen PHP. Het gebeurt heel vaak dat je data uit moet wisselen met andere talen of applicaties. En dan kan het wel eens wel problematisch zijn.

JSON is tegenwoordig de de facto standaard om data uit te wisselen tussen verschillende applicaties en talen. Elke taal heeft functies om een JSON string te decoderen naar een interne datastructuur, en omgekeerd die structuren naar een JSON string te encoden.

Bij deze conversies is het wel belangrijk dat deze volledig symmetrisch is. Met andere woorden: als je data naar json omzet, en weer terug, dan mag die data niet veranderd zijn.

Met een taal waar een array zowel een list als een dictionary kan zijn, geeft dat wel eens problemen.

$json = '{"0": "No", "1": "Yes"}';
$array = json_decode($json, true);
print json_encode($array);

Je zou de oorspronkelijke JSON string verwachten, maar dit geeft in werkelijkheid een heel ander resultaat:

["No","Yes"]

Een dictionary wordt opeens een list! De conversie is in dit geval dus niet symmetrisch!

Andersom gebeurt het ook:

$array = [
    'first',
    'second',
    'third',
];
print json_encode($array) . PHP_EOL;
// remove the second element
unset($array[1]);
print json_encode($array) . PHP_EOL;

Hier wordt een array opeens een dictionary!

["first","second","third"]
{"0":"first","2":"third"}

Hoe komt dit nou?

Een array in PHP is een list als het opeenvolgende, numerieke keys heeft, beginnend met 0. Een dergelijke array wordt na JSON conversie ook een list.

In alle andere gevallen is het dus eigenlijk een dictionary en wordt het na JSON conversie een object.

Tot voor kort bestond er geen aparte functie om het "array type" te checken. Maar, met de komst van PHP8.1 is er eindelijk de functie array_is_list. Beter laat dan nooit, zullen we maar zeggen.

Mocht je nou nog niet met 8.1 werken, dan kun je deze simpele polyfill gebruiken:

if (!function_exists('array_is_list')) {
    function array_is_list(array $a): bool
    {
        if ($a === []) {
            return true;
        }

        return array_keys($a) === range(0, count($a) - 1);
    }
}

Deze functie is op zich soms wel handig, maar is zeker geen fix voor alles. Het had me niet behoed voor de valkuil hierboven. Want het voorbeeld (en eigenlijk deze hele blogpost) is geinspireerd op een praktijk case.

$json = '{"0": "No", "1": "Yes"}';
$array = json_decode($json, true);
print json_encode($array);

Bovenstaande stukje JSON was deel van een veel groter JSON document, dat ergens in een database stond. Ik moest het omzetten naar PHP zodat ik er een aantal transformaties op kon uitvoeren, het daarna weer terug naar JSON converteren en in de DB updaten.

Let op het tweede argument voor json_decode. De true zorgt ervoor dat de JSON omgezet wordt in arrays, in plaats van stdClass objecten. Ik doe dit zelf eigenlijk bijna altijd zo, omdat een array over het algemeen veel makkelijker is om mee te werken dan stdClass objecten.

Er bestaat in PHP voor arrays een hele verzameling aan array_* functies, maar bijna niks voor objecten. Als je bijvoorbeeld twee objecten wil mergen, is het handig om deze om te zetten naar arrays, deze in array_merge te gooien, en dan eventueel weer naar een object te casten. Dus waarom dan uberhaupt stdClass objecten gebruiken?

Nou, inmiddels weet ik dat wel! De JSON dictionary {"0": "No", "1": "Yes"} heeft opeenvolgende numerieke keys. Ja, het zijn weliswaar strings, maar hey, we hebben het over PHP, die converteert dat gewoon stilletjes naar integers! Dus de dictionary veranderde in PHP en daarna weer in JSON in een list. Met als gevolg dat onverwacht ergens een formulier kapotging. Gelukkig had ik de backups nog.

  • Arrays kunnen verraderlijk zijn in PHP.

  • Ze kunnen "onder water" een list of een dictionary zijn. Daar merk je meestal pas wat van als je ze van of naar een formaat als JSON converteert.

  • Als je JSON decodeert die je ook weer terug naar JSON moet encoderen, decodeer dan liever naar een object, niet naar een array.

  • Als je een PHP array geforceerd naar een JSON list wil laten encoden:

json_encode(array_values($array));
  • En als je een PHP array geforceerd naar een JSON object wil laten encoden:
json_encode((object)$array);

Gerelateerde posts

image
Een nieuw blogplatform

Ik vond het wel eens tijd om wat meer over mijn vak te schrijven. Dus heb ik een blog opgezet. Hiervoor heb mijn eigen custom blogplatform gebouwd. En daar schrijf ik dus meteen maar een eerste post over.

Lees meer →

image
Sublime Text als volwaardige PHP IDE

Sublime Text lijkt misschien op eerste gezicht meer een text editor dan een IDE, maar schijn bedriegt. Lees hier waarom ik Sublime Text prefereer boven andere editors of IDEs, en hoe je er het meeste uit kan halen met behulp van de juiste plugins.

Lees meer →

image
Pass, the standard Unix password manager

Bijna iedereen gebruikt tegenwoordig een password manager. LastPass, 1Password, Keepass, etc. Maar slechts weinig mensen kennen "pass", de unix password manager. In deze post leg ik uit waarom pass een goede optie kan zijn voor de handige linux/unix nerd.

Lees meer →