Vor kurzem wollte ich aus einem <code>-Element die Sprache der enthaltenen Programmiersprache extrahieren. HTML5 etablierte hierfür schon in der Jungsteinzeit eine Konvention, nach der diese Information in der Klasse des fraglichen <code>-Elements zu lagern hat:

<code class="language-javascript">/* JS-Code hier */<code>

Im Prinzip ist das keine große Herausforderung: wir greifen uns den className des DOM-Knotens, wenden den regulären Ausdruck /language-(\S*)/ darauf an und geben aus, was auch immer die Capturing Group eingefangen hat (in diesem Fall 0 bis N Nicht-Whitespace-Zeichen). Wenn der reguläre Ausdruck entweder gar nicht matcht oder die Capturing Group null Zeichen einsammelt, soll als Fallback der String "none" herauskommen. Zu Testzwecken können wir die DOM-Elemente durch normale JavaScript-Objekte ersetzen und uns unseren Dreamcode aufmalen:

// So soll's funktionieren
console.log(getLanguage({ className: "language-c" })); // "c"
console.log(getLanguage({ className: "foo language-json bar" })); // "json"
console.log(getLanguage({ className: "foo language- bar" })); // "none"
console.log(getLanguage({ className: "foo bar" })); // "none"
console.log(getLanguage({ className: "" })); // "none"

Es gibt natürlich eine Menge in diesem Beispiel nicht berücksichtigte Edge Cases, aber es ist ja auch nur ein vereinfachtes Beispiel. Implementieren wir doch einfach mal die Funktion getLanguage() auf die Weise, wie man es in JavaScript seit der bereits angesprochen Jungsteinzeit macht:

function getLanguage(source) {
  const match = /language-(\S*)/.exec(source.className);
  if (match && match[1]) {
    return match[1];
  }
  return "none";
}

Die Regexp-Methode exec() liefert entweder null oder ein etwas seltsames Array, das neben den Ergebnissen des Regexp-Matches noch allerlei Extrafelder enthält. Technisch gesehen ist das kein Problem, denn Arrays sind in JavaScript fast ganz normale Objekte und das einzig besondere an ihnen sind die numerischen Keys und die sich automatisch anpassende length – doch weitere Felder, wie im Falle des RegExpArrays index und input, können in JS auf im Prinzip jedes Array gesteckt werden. Der Umgang mit dem Rückgabewert von exec() ist das eigentliche in unserer Funktion zu lösende Problem. Wir müssen uns in getLanguage() sowohl gegen null wappnen, als auch dagegen, dass die Capturing Group (\S*) bei Inputs wie "language-" einen leeren String liefert. Alles ist bedacht und die Funktion macht genau, was sie machen soll.

Das einzige Problem mit dem obigen Code ist: der Jungsteinzeit entsprechend ist das JavaScript auf syntaktischem Faustkeil-Niveau. Wir setzen eine ganze Menge Zeichen und Statements für eine recht einfache Aufgabe ein! Spielen wir doch einmal eine Runde Code-Golf und versuchen, die Funktion etwas kompakter zu gestalten – ohne die Lesbarkeit allzu arg leiden zu lassen.

Das Handling von null können wir auf relativ unkontroverse Weise abdecken:

function getLanguage(source) {
  const match = /language-(\S*)/.exec(source.className) ?? [];
  if (match[1]) {
    return match[1];
  }
  return "none";
}

Jetzt wird match im Erfolgsfall ein RegexpArray und im Nicht-Match-Fall ein leeres Array enthalten. Der halbwegs neue Nullish Coalescing Operator ?? könnte an dieser Stelle im Prinzip auch durch althergebrachte || ersetzt werden, wobei ersteres die etwas präzisere Formulierung der angestrebten Operation ist. Während das logische Oder a || b zu b evaluiert, wenn a falsy ist, liefert a ?? b nur b, wenn a entweder null oder undefined ist. Normalerweise würde || eine mögliche Fehlerquelle darstellen, da auch leere Strings, die Zahl 0 usw. zu b führen könnten, aber das kann hier nicht passieren (null und ein Array sind die einzigen möglichen Werte). Trotzdem bleiben wir einfach mal bei ??, weil das etwas genauer ausdrückt, was hier unsere Intention ist.

Da wir nun sicher sind, dass match immer ein Array ist, können wir an das zweite Element (den von der Capturing Group eingefangenen Wert) auch per Destructuring statt mit Index-Zugriff herankommen:

function getLanguage(source) {
  const [ , match ] = /language-(\S*)/.exec(source.className) ?? [];
  if (match) {
    return match;
  }
  return "none";
}

Durch ein (oder mehrere) Extra-Kommata in einem Array-Destructuring können wir Elemente überspringen und so recht bequem an das in unserem Fall zweite Element herankommen. Sollten der reguläre Ausdruck keinen Treffer gefunden haben und wir auf das leere Array zurückfallen, würde match zu undefined, was das If-Statement abfängt.

Dieses If-Statement könnten wir nun durch logisches Oder (|| statt ??, um auch leere Strings zu none zu machen) ersetzen und damit den Code auf ganze zwei Zeilen eindampfen:

function getLanguage(source) {
  const [, match] = /language-(\S*)/.exec(source.className) ?? [];
  return match || "none";
}

So weit, so okay, aber ein bisschen viel Hexerei mit sehr ähnlichen Operatoren. Da geht noch was! Wenn wir den regulären Ausdruck /language-(\S*)/ durch /language-(\S+)/ ersetzen, können wir verhindern, dass der String "language-" matcht und damit ein leerer String aus der Capturing Group fällt. Unter diesen Umständen können wir das logische Oder durch einen Default-Wert im Destructuring Assignment ersetzen:

function getLanguage(source) {
  const [, match = "none"] = /language-(\S+)/.exec(source.className) ?? [];
  return match;
}

Das lässt sich zwar jetzt einigermaßen gut als „match wird das zweite Element oder "none"“ lesen, aber so richtig gut gefällt es mir noch nicht. Das Komma, das das erste Element im Destructuring Assignment überspringt, springt uns optisch nicht gerade entgegen und in Fällen, in denen es mehr als ein Komma gibt, wird der gesamte Ausdruck noch schwieriger zu verstehen:

// Das wievielte Element ist jetzt was?
const [,, foo, bar ,, baz] = ["a", "b", "c", "d", "e", "f", "g", "h"];

Doch der Kommasalat muss nicht sein: Objekt-Destructuring für Arrays hilft! Denn Arrays sind, wie beim RegExpArray schon erwähnt, in erster Näherung nur eine Sonderform von JS-Objekten mit ein paar Konventionen (numerische Keys, length-Property). Dass sie mit dem sogenannten Array-Destructuring verwendet werden können, liegt daran, dass sie das entsprechende Iterator-Protokoll implementieren, aber es spricht nichts dagegen, sie wie ganz normale Objekte mit Objekt-Destructuring zu behandeln:

const arr = ["a", "b", "c", "d", "e", "f", "g", "h"];
const [ first ] = arr; // > klappt, ergibt "a"
const { length } = arr; // > klappt, ergibt 8

Während wir in diesem Beispiel auf die Information length anhand ihres Namens zugreifen, greifen wir auf den Wert first anhand seiner Position im Array zu. Allerdings hat auch first einen Namen im Array, nämlich seinen Index bzw. Objekt-Key: 0. Auf diesen können wir nicht ganz ohne weiteres in Objekt-Destructuring zugreifen …

const arr = ["a", "b", "c", "d", "e", "f", "g", "h"];
const { 0 } = arr; // > SyntaxError

… was aber allein daran liegt, dass 0 kein gültiger Variablenname ist. Extrahieren wir aber 0 aus dem Array/Objekt und überführen es im Destructuring Assignment in einen neuen Namen, klappt es ganz problemlos:

const arr = ["a", "b", "c", "d", "e", "f", "g", "h"];
const { 0: first, 2: third } = arr;
// first = "a", third = "c"

Ich persönlich verwende Array-Destructuring fast nur, wenn die Liste der zu extrahierenden Elementen an Stelle 0 beginnt und verwende ansonsten lieber Objekt-Destructuring. Es liest sich einfach viel sprechender als eine Reihe von Kommata:

function getLanguage(source) {
  const { 1: match = "none" } = /language-(\S+)/.exec(source.className) ?? [];
  return match;
}

In Klartext: wende den regulären Ausdruck an, greife den zweiten Treffer aus dem garantiert nicht null-wertigen (RegExp-) Array heraus, überführe ihn in die Variable match und setze "none" ein, falls es keinen zweiten Treffer gibt. Bäm!

Ob diese konkrete Funktion jetzt wirklich der syntaktische Hauptgewinn ist, sei mal dahingestellt, denn das Sonderzeichen-Rauschen ist vergleichsweise intensiv. Möglicherweise könnte es helfen, den regulären Ausdruck in eine eigene Variable auszulagern, doch egal, was für Feintuning noch möglich wäre: Wir können aus unserer Tour über den Golfplatz zwei, wie ich finde, universelle Erkenntnisse für kommende Programmierabenteuer mitnehmen:

  1. In vielen Fällen kann || den Job von ?? übernehmen, gerade wenn wir uns aus der statischen TypeScript-Welt heraushalten. Auch wenn es oft (und auch in unserem Beispiel mit exec()) wirklich keinen Unterschied macht, finde ich, dass eine bewusste Wahl der Operatoren schon dazu beiträgt, die Intention einer Zeile Code exakt zu kommunizieren.
  2. Dass Arrays am Ende des Tages normale Objekte sind, macht sie zu einem potenziellen Ziel für alle möglichen Objekt-Operationen. Wir können Objekt-Destructuring auf Arrays anwenden und auch Object.assign([,,1], [2]) funktioniert und liefert das erwartete Ergebnis – keine Notwendingkeit für irgendwelche Array-spezifischen Funktionen!

Fest steht aber auch: jeder Gewinn an Lesbarkeit, Produktivität oder Performance löst sich in Rauch auf, sobald man sich nach dem Update von zwei JavaScript-Zeilen zum Schreiben eines ellenlangen Artikels über Code-Golf veranlasst sieht. Trotzdem vielen Dank an die Testleser Andreas, morbidick, Stefan und Frederik!