Deep JavaScript
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

2 Typumwandlung in JavaScript



In diesem Kapitel untersuchen wir die Rolle der Typumwandlung in JavaScript. Wir werden dieses Thema relativ tiefgehend behandeln und uns z. B. ansehen, wie die ECMAScript-Spezifikation Typumwandlung handhabt.

2.1 Was ist Typumwandlung?

Jede Operation (Funktion, Operator usw.) erwartet, dass ihre Parameter bestimmte Typen haben. Wenn ein Wert nicht den richtigen Typ für einen Parameter hat, gibt es drei gängige Optionen, z. B. für eine Funktion:

  1. Die Funktion kann eine Ausnahme auslösen

    function multiply(x, y) {
      if (typeof x !== 'number' || typeof y !== 'number') {
        throw new TypeError();
      }
      // ···
    }
  2. Die Funktion kann einen Fehlerwert zurückgeben

    function multiply(x, y) {
      if (typeof x !== 'number' || typeof y !== 'number') {
        return NaN;
      }
      // ···
    }
  3. Die Funktion kann ihre Argumente in nützliche Werte umwandeln

    function multiply(x, y) {
      if (typeof x !== 'number') {
        x = Number(x);
      }
      if (typeof y !== 'number') {
        y = Number(y);
      }
      // ···
    }

Bei (3) führt die Operation eine implizite Typumwandlung durch. Dies wird als Typumwandlung bezeichnet.

JavaScript hatte anfangs keine Ausnahmen, weshalb es für die meisten seiner Operationen Typumwandlung und Fehlerwerte verwendet.

// Coercion
assert.equal(3 * true, 3);

// Error values
assert.equal(1 / 0, Infinity);
assert.equal(Number('xyz'), NaN);

Es gibt jedoch auch Fälle (insbesondere bei neueren Funktionen), in denen Ausnahmen ausgelöst werden, wenn ein Argument nicht den richtigen Typ hat.

2.1.1 Umgang mit Typumwandlung

Zwei gängige Methoden, mit Typumwandlung umzugehen, sind:

Ich bevorzuge normalerweise die erstere Methode, da sie meine Absicht verdeutlicht: Ich erwarte, dass x und y keine Zahlen sind, möchte aber zwei Zahlen multiplizieren.

2.2 Operationen, die bei der Implementierung von Typumwandlung in der ECMAScript-Spezifikation helfen

Die folgenden Abschnitte beschreiben die wichtigsten internen Funktionen, die von der ECMAScript-Spezifikation verwendet werden, um tatsächliche Parameter in erwartete Typen umzuwandeln.

Zum Beispiel würden wir in TypeScript schreiben:

function isNaN(number: number) {
  // ···
}

In der Spezifikation sieht dies wie folgt aus (übersetzt in JavaScript, um das Verständnis zu erleichtern):

function isNaN(number) {
  let num = ToNumber(number);
  // ···
}

2.2.1 Umwandlung in primitive Typen und Objekte

Immer wenn primitive Typen oder Objekte erwartet werden, werden die folgenden Umwandlungsfunktionen verwendet:

Diese internen Funktionen haben Entsprechungen in JavaScript, die sehr ähnlich sind.

> Boolean(0)
false
> Boolean(1)
true

> Number('123')
123

Nach der Einführung von BigInts, die neben Zahlen existieren, verwendet die Spezifikation häufig ToNumeric(), wo sie zuvor ToNumber() verwendete. Lesen Sie weiter für mehr Informationen.

2.2.2 Umwandlung in numerische Typen

Derzeit hat JavaScript zwei eingebaute numerische Typen: number und bigint.

Tabelle 1: Typumwandlung der Operanden von bitweisen numerischen Operatoren (BigInt-Operatoren begrenzen die Bitanzahl nicht).
Operator Linker Operand Rechter Operand Ergebnistyp
<< ToInt32() ToUint32() Int32
vorzeichenbehaftetes >> ToInt32() ToUint32() Int32
vorzeichenloses >>> ToInt32() ToUint32() Uint32
&, ^, | ToInt32() ToUint32() Int32
~ ToInt32() Int32

2.2.3 Umwandlung in Eigenschaftsschlüssel

ToPropertyKey() gibt einen String oder ein Symbol zurück und wird verwendet von:

2.2.4 Umwandlung in Array-Indizes

2.2.5 Umwandlung in Typed Array-Elemente

Wenn wir den Wert eines Typed Array-Elements setzen, wird eine der folgenden Umwandlungsfunktionen verwendet:

2.3 Zwischenspiel: Spezifikationsalgorithmen in JavaScript ausdrücken

Im Rest dieses Kapitels werden wir mehrere Spezifikationsalgorithmen antreffen, die aber als JavaScript „implementiert“ sind. Die folgende Liste zeigt, wie einige häufig verwendete Muster von der Spezifikation in JavaScript übersetzt werden:

let (und nicht const) wird verwendet, um der Sprache der Spezifikation zu entsprechen.

Einige Dinge werden weggelassen – zum Beispiel die ReturnIfAbrupt-Kurzformen ? und !.

/**
 * An improved version of typeof
 */
function TypeOf(value) {
  const result = typeof value;
  switch (result) {
    case 'function':
      return 'object';
    case 'object':
      if (value === null) {
        return 'null';
      } else {
        return 'object';
      }
    default:
      return result;
  }
}

function IsCallable(x) {
  return typeof x === 'function';
}

2.4 Beispielalgorithmen für Typumwandlung

2.4.1 ToPrimitive()

Die Operation ToPrimitive() ist ein Zwischenschritt für viele Typumwandlungsalgorithmen (von denen einige wir später in diesem Kapitel sehen werden). Sie wandelt beliebige Werte in primitive Werte um.

ToPrimitive() wird in der Spezifikation häufig verwendet, da die meisten Operatoren nur mit primitiven Werten arbeiten können. Zum Beispiel können wir den Additionsoperator (+) verwenden, um Zahlen zu addieren und Strings zu verketten, aber wir können ihn nicht verwenden, um Arrays zu verketten.

Dies ist, wie die JavaScript-Version von ToPrimitive() aussieht:

/**
 * @param hint Which type is preferred for the result:
 *             string, number, or don’t care?
 */
function ToPrimitive(input: any,
  hint: 'string'|'number'|'default' = 'default') {
    if (TypeOf(input) === 'object') {
      let exoticToPrim = input[Symbol.toPrimitive]; // (A)
      if (exoticToPrim !== undefined) {
        let result = exoticToPrim.call(input, hint);
        if (TypeOf(result) !== 'object') {
          return result;
        }
        throw new TypeError();
      }
      if (hint === 'default') {
        hint = 'number';
      }
      return OrdinaryToPrimitive(input, hint);
    } else {
      // input is already primitive
      return input;
    }
  }

ToPrimitive() erlaubt Objekten, die Umwandlung in primitive Werte über Symbol.toPrimitive (Zeile A) zu überschreiben. Wenn ein Objekt dies nicht tut, wird es an OrdinaryToPrimitive() weitergeleitet.

function OrdinaryToPrimitive(O: object, hint: 'string' | 'number') {
  let methodNames;
  if (hint === 'string') {
    methodNames = ['toString', 'valueOf'];
  } else {
    methodNames = ['valueOf', 'toString'];
  }
  for (let name of methodNames) {
    let method = O[name];
    if (IsCallable(method)) {
      let result = method.call(O);
      if (TypeOf(result) !== 'object') {
        return result;
      }
    }
  }
  throw new TypeError();
}
2.4.1.1 Welche Hinweise verwenden Aufrufer von ToPrimitive()?

Der Parameter hint kann einen von drei Werten annehmen:

Hier sind einige Beispiele dafür, wie verschiedene Operationen ToPrimitive() verwenden:

Wie wir gesehen haben, ist das Standardverhalten für 'default', als ob es 'number' wäre. Nur Instanzen von Symbol und Date überschreiben dieses Verhalten (später gezeigt).

2.4.1.2 Welche Methoden werden aufgerufen, um Objekte in primitive Werte umzuwandeln?

Wenn die Umwandlung in primitive Werte nicht über Symbol.toPrimitive überschrieben wird, ruft OrdinaryToPrimitive() entweder eine oder beide der folgenden beiden Methoden auf:

Der folgende Code demonstriert, wie das funktioniert:

const obj = {
  toString() { return 'a' },
  valueOf() { return 1 },
};

// String() prefers strings:
assert.equal(String(obj), 'a');

// Number() prefers numbers:
assert.equal(Number(obj), 1);

Eine Methode mit dem Eigenschaftsschlüssel Symbol.toPrimitive überschreibt die normale Umwandlung in primitive Werte. Dies wird nur zweimal in der Standardbibliothek gemacht:

2.4.1.3 Date.prototype[Symbol.toPrimitive]()

So behandeln Dates die Umwandlung in primitive Werte:

Date.prototype[Symbol.toPrimitive] = function (
  hint: 'default' | 'string' | 'number') {
    let O = this;
    if (TypeOf(O) !== 'object') {
      throw new TypeError();
    }
    let tryFirst;
    if (hint === 'string' || hint === 'default') {
      tryFirst = 'string';
    } else if (hint === 'number') {
      tryFirst = 'number';
    } else {
      throw new TypeError();
    }
    return OrdinaryToPrimitive(O, tryFirst);
  };

Der einzige Unterschied zum Standardalgorithmus besteht darin, dass 'default' zu 'string' (und nicht zu 'number') wird. Dies kann beobachtet werden, wenn wir Operationen verwenden, die hint auf 'default' setzen:

Dies ist die JavaScript-Version von ToString():

function ToString(argument) {
  if (argument === undefined) {
    return 'undefined';
  } else if (argument === null) {
    return 'null';
  } else if (argument === true) {
    return 'true';
  } else if (argument === false) {
    return 'false';
  } else if (TypeOf(argument) === 'number') {
    return Number.toString(argument);
  } else if (TypeOf(argument) === 'string') {
    return argument;
  } else if (TypeOf(argument) === 'symbol') {
    throw new TypeError();
  } else if (TypeOf(argument) === 'bigint') {
    return BigInt.toString(argument);
  } else {
    // argument is an object
    let primValue = ToPrimitive(argument, 'string'); // (A)
    return ToString(primValue);
  }
}

Beachten Sie, wie diese Funktion ToPrimitive() als Zwischenschritt für Objekte verwendet, bevor das primitive Ergebnis in einen String umgewandelt wird (Zeile A).

ToString() weicht auf interessante Weise von der Funktionsweise von String() ab: Wenn argument ein Symbol ist, löst die erstere einen TypeError aus, während die letztere dies nicht tut. Warum? Der Standard für Symbole ist, dass die Umwandlung in Strings Ausnahmen auslöst.

> const sym = Symbol('sym');

> ''+sym
TypeError: Cannot convert a Symbol value to a string
> `${sym}`
TypeError: Cannot convert a Symbol value to a string

Dieser Standard wird in String() und Symbol.prototype.toString() (beide werden in den nächsten Unterabschnitten beschrieben) überschrieben.

> String(sym)
'Symbol(sym)'
> sym.toString()
'Symbol(sym)'
2.4.2.1 String()
function String(value) {
  let s;
  if (value === undefined) {
    s = '';
  } else {
    if (new.target === undefined && TypeOf(value) === 'symbol') {
      // This function was function-called and value is a symbol
      return SymbolDescriptiveString(value);
    }
    s = ToString(value);
  }
  if (new.target === undefined) {
    // This function was function-called
    return s;
  }
  // This function was new-called
  return StringCreate(s, new.target.prototype); // simplified!
}

String() funktioniert unterschiedlich, je nachdem, ob es über einen Funktionsaufruf oder über new aufgerufen wird. Es verwendet new.target, um die beiden zu unterscheiden.

Dies sind die Hilfsfunktionen StringCreate() und SymbolDescriptiveString():

/** 
 * Creates a String instance that wraps `value`
 * and has the given protoype.
 */
function StringCreate(value, prototype) {
  // ···
}

function SymbolDescriptiveString(sym) {
  assert.equal(TypeOf(sym), 'symbol');
  let desc = sym.description;
  if (desc === undefined) {
    desc = '';
  }
  assert.equal(TypeOf(desc), 'string');
  return 'Symbol('+desc+')';
}
2.4.2.2 Symbol.prototype.toString()

Neben String() können wir auch die Methode .toString() verwenden, um ein Symbol in einen String umzuwandeln. Seine Spezifikation sieht wie folgt aus.

Symbol.prototype.toString = function () {
  let sym = thisSymbolValue(this);
  return SymbolDescriptiveString(sym);
};
function thisSymbolValue(value) {
  if (TypeOf(value) === 'symbol') {
    return value;
  }
  if (TypeOf(value) === 'object' && '__SymbolData__' in value) {
    let s = value.__SymbolData__;
    assert.equal(TypeOf(s), 'symbol');
    return s;
  }
}
2.4.2.3 Object.prototype.toString

Die Standard-Spezifikation für .toString() sieht wie folgt aus:

Object.prototype.toString = function () {
  if (this === undefined) {
    return '[object Undefined]';
  }
  if (this === null) {
    return '[object Null]';
  }
  let O = ToObject(this);
  let isArray = Array.isArray(O);
  let builtinTag;
  if (isArray) {
    builtinTag = 'Array';
  } else if ('__ParameterMap__' in O) {
    builtinTag = 'Arguments';
  } else if ('__Call__' in O) {
    builtinTag = 'Function';
  } else if ('__ErrorData__' in O) {
    builtinTag = 'Error';
  } else if ('__BooleanData__' in O) {
    builtinTag = 'Boolean';
  } else if ('__NumberData__' in O) {
    builtinTag = 'Number';
  } else if ('__StringData__' in O) {
    builtinTag = 'String';
  } else if ('__DateValue__' in O) {
    builtinTag = 'Date';
  } else if ('__RegExpMatcher__' in O) {
    builtinTag = 'RegExp';
  } else {
    builtinTag = 'Object';
  }
  let tag = O[Symbol.toStringTag];
  if (TypeOf(tag) !== 'string') {
    tag = builtinTag;
  }
  return '[object ' + tag + ']';
};

Diese Operation wird verwendet, wenn wir einfache Objekte in Strings umwandeln.

> String({})
'[object Object]'

Standardmäßig wird sie auch verwendet, wenn wir Instanzen von Klassen in Strings umwandeln.

class MyClass {}
assert.equal(
  String(new MyClass()), '[object Object]');

Normalerweise würden wir .toString() überschreiben, um die String-Darstellung von MyClass zu konfigurieren, aber wir können auch ändern, was nach „object“ innerhalb des Strings steht, mit den eckigen Klammern.

class MyClass {}
MyClass.prototype[Symbol.toStringTag] = 'Custom!';
assert.equal(
  String(new MyClass()), '[object Custom!]');

Es ist interessant, die überschriebenen Versionen von .toString() mit der ursprünglichen Version in Object.prototype zu vergleichen.

> ['a', 'b'].toString()
'a,b'
> Object.prototype.toString.call(['a', 'b'])
'[object Array]'

> /^abc$/.toString()
'/^abc$/'
> Object.prototype.toString.call(/^abc$/)
'[object RegExp]'

2.4.3 ToPropertyKey()

ToPropertyKey() wird unter anderem vom Klammeroperator verwendet. So funktioniert es:

function ToPropertyKey(argument) {
  let key = ToPrimitive(argument, 'string'); // (A)
  if (TypeOf(key) === 'symbol') {
    return key;
  }
  return ToString(key);
}

Auch hier werden Objekte in primitive Werte umgewandelt, bevor mit primitiven Werten gearbeitet wird.

ToNumeric() wird unter anderem vom Multiplikationsoperator (*) verwendet. So funktioniert es:

function ToNumeric(value) {
  let primValue = ToPrimitive(value, 'number');
  if (TypeOf(primValue) === 'bigint') {
    return primValue;
  }
  return ToNumber(primValue);
}
2.4.4.1 ToNumber()

ToNumber() funktioniert wie folgt:

function ToNumber(argument) {
  if (argument === undefined) {
    return NaN;
  } else if (argument === null) {
    return +0;
  } else if (argument === true) {
    return 1;
  } else if (argument === false) {
    return +0;
  } else if (TypeOf(argument) === 'number') {
    return argument;
  } else if (TypeOf(argument) === 'string') {
    return parseTheString(argument); // not shown here
  } else if (TypeOf(argument) === 'symbol') {
    throw new TypeError();
  } else if (TypeOf(argument) === 'bigint') {
    throw new TypeError();
  } else {
    // argument is an object
    let primValue = ToPrimitive(argument, 'number');
    return ToNumber(primValue);
  }
}

Die Struktur von ToNumber() ist ähnlich der Struktur von ToString().

2.5 Operationen, die Typumwandlung durchführen

2.5.1 Additionsoperator (+)

So ist der Additionsoperator von JavaScript spezifiziert:

function Addition(leftHandSide, rightHandSide) {
  let lprim = ToPrimitive(leftHandSide);
  let rprim = ToPrimitive(rightHandSide);
  if (TypeOf(lprim) === 'string' || TypeOf(rprim) === 'string') { // (A)
    return ToString(lprim) + ToString(rprim);
  }
  let lnum = ToNumeric(lprim);
  let rnum = ToNumeric(rprim);
  if (TypeOf(lnum) !== TypeOf(rnum)) {
    throw new TypeError();
  }
  let T = Type(lnum);
  return T.add(lnum, rnum); // (B)
}

Schritte dieses Algorithmus:

2.5.2 Abstrakter Gleichheitsvergleich (==)

/** Loose equality (==) */
function abstractEqualityComparison(x, y) {
  if (TypeOf(x) === TypeOf(y)) {
    // Use strict equality (===)
    return strictEqualityComparison(x, y);
  }

  // Comparing null with undefined
  if (x === null && y === undefined) {
    return true;
  }
  if (x === undefined && y === null) {
    return true;
  }

  // Comparing a number and a string
  if (TypeOf(x) === 'number' && TypeOf(y) === 'string') {
    return abstractEqualityComparison(x, Number(y));
  }
  if (TypeOf(x) === 'string' && TypeOf(y) === 'number') {
    return abstractEqualityComparison(Number(x), y);
  }

  // Comparing a bigint and a string
  if (TypeOf(x) === 'bigint' && TypeOf(y) === 'string') {
    let n = StringToBigInt(y);
    if (Number.isNaN(n)) {
      return false;
    }
    return abstractEqualityComparison(x, n);
  }
  if (TypeOf(x) === 'string' && TypeOf(y) === 'bigint') {
    return abstractEqualityComparison(y, x);
  }

  // Comparing a boolean with a non-boolean
  if (TypeOf(x) === 'boolean') {
    return abstractEqualityComparison(Number(x), y);
  }
  if (TypeOf(y) === 'boolean') {
    return abstractEqualityComparison(x, Number(y));
  }

  // Comparing an object with a primitive
  // (other than undefined, null, a boolean)
  if (['string', 'number', 'bigint', 'symbol'].includes(TypeOf(x))
    && TypeOf(y) === 'object') {
      return abstractEqualityComparison(x, ToPrimitive(y));
    }
  if (TypeOf(x) === 'object'
    && ['string', 'number', 'bigint', 'symbol'].includes(TypeOf(y))) {
      return abstractEqualityComparison(ToPrimitive(x), y);
    }
  
  // Comparing a bigint with a number
  if ((TypeOf(x) === 'bigint' && TypeOf(y) === 'number')
    || (TypeOf(x) === 'number' && TypeOf(y) === 'bigint')) {
      if ([NaN, +Infinity, -Infinity].includes(x)
        || [NaN, +Infinity, -Infinity].includes(y)) {
          return false;
        }
      if (isSameMathematicalValue(x, y)) {
        return true;
      } else {
        return false;
      }
    }
  
  return false;
}

Die folgenden Operationen werden hier nicht gezeigt:

Nachdem wir uns nun genauer angesehen haben, wie die Typumwandlung in JavaScript funktioniert, schließen wir mit einem kurzen Glossar der Begriffe im Zusammenhang mit Typumwandlung ab:

[Quelle: Wikipedia]