Deep JavaScript
本書をサポートしてください:購入する または 寄付する
(広告です。ブロックしないでください。)

2 JavaScriptにおける型強制



この章では、JavaScriptにおける型強制の役割を調べます。このトピックについて比較的深く掘り下げ、例えばECMAScript仕様がどのように型強制を扱うかを見ていきます。

2.1 型強制とは何か?

各演算(関数、演算子など)は、そのパラメータが特定の型を持つことを期待します。値がパラメータに適切な型を持っていない場合、関数などに対しては3つの一般的な選択肢があります。

  1. 関数が例外をスローする

    function multiply(x, y) {
      if (typeof x !== 'number' || typeof y !== 'number') {
        throw new TypeError();
      }
      // ···
    }
  2. 関数がエラー値を返す

    function multiply(x, y) {
      if (typeof x !== 'number' || typeof y !== 'number') {
        return NaN;
      }
      // ···
    }
  3. 関数がその引数を有用な値に変換する

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

(3)では、演算は暗黙的な型変換を実行します。これは型強制と呼ばれます。

JavaScriptは当初例外を持たなかったため、ほとんどの演算で型強制とエラー値を使用しています。

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

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

しかし、(特に新しい機能に関しては)引数が適切な型を持っていない場合に例外をスローするケースもあります。

2.1.1 型強制への対処法

型強制に対処する2つの一般的な方法は次のとおりです。

私は通常、前者の方が好みです。なぜなら、私の意図を明確にするからです。私はxyが数値ではないことを期待していますが、2つの数値を掛け合わせたいのです。

2.2 ECMAScript仕様における型強制の実装を支援する演算

以降のセクションでは、ECMAScript仕様で実際の引数を期待される型に変換するために使用される最も重要な内部関数を説明します。

例えば、TypeScriptでは次のように記述します。

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

仕様では、これは次のようになります(JavaScriptに翻訳されているため、理解しやすくなっています)。

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

2.2.1 プリミティブ型とオブジェクトへの変換

プリミティブ型またはオブジェクトが期待される場合は、次の変換関数が使用されます。

これらの内部関数には、JavaScriptで非常に類似したアナログがあります。

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

> Number('123')
123

数値と並んで存在するBigIntの導入後、仕様では以前ToNumber()を使用していた場所にToNumeric()を頻繁に使用しています。詳細については、読み進めてください。

2.2.2 数値型への変換

現在、JavaScriptには2つの組み込み数値型があります:numberとbigint。

表1:ビット演算数値演算子のオペランドの型強制(BigInt演算子はビット数を制限しません)。
演算子 左オペランド 右オペランド 結果の型
<< ToInt32() ToUint32() Int32
符号付き >> ToInt32() ToUint32() Int32
符号なし >>> ToInt32() ToUint32() Uint32
&, ^, | ToInt32() ToUint32() Int32
~ ToInt32() Int32

2.2.3 プロパティキーへの変換

ToPropertyKey()は文字列またはシンボルを返し、次で使用されます。

2.2.4 配列インデックスへの変換

2.2.5 型付き配列要素への変換

型付き配列要素の値を設定する場合、次の変換関数のいずれかが使用されます。

2.3 休憩:JavaScriptにおける仕様アルゴリズムの表現

この章の残りの部分では、いくつかの仕様アルゴリズムに遭遇しますが、「実装」はJavaScriptで行われます。次のリストは、頻繁に使用されるパターンを仕様からJavaScriptにどのように変換するかを示しています。

仕様の言語に合わせるため、letconstではなく)を使用しています。

いくつかのものは省略されています。たとえば、ReturnIfAbruptショートハンド?!です。

/**
 * 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 型強制アルゴリズムの例

2.4.1 ToPrimitive()

演算ToPrimitive()は、多くの型強制アルゴリズムの中間ステップです(この章の後半でいくつか見ていきます)。これは任意の値をプリミティブ値に変換します。

ToPrimitive()は仕様で頻繁に使用されます。なぜなら、ほとんどの演算子はプリミティブ値でのみ動作できるためです。たとえば、加算演算子(+)を使用して数値を加算し、文字列を連結できますが、配列を連結するには使用できません。

これが、ToPrimitive()のJavaScriptバージョンです。

/**
 * @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()は、オブジェクトがSymbol.toPrimitive(A行)を介してプリミティブへの変換をオーバーライドできるようにします。オブジェクトがそうしない場合、それはOrdinaryToPrimitive()に渡されます。

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 ToPrimitive()の呼び出し元はどのヒントを使用しますか?

パラメータhintには、3つの値のいずれかを取ることができます。

さまざまな演算がToPrimitive()を使用する方法の例をいくつか示します。

見てきたように、デフォルトの動作では'default''number'であるかのように扱われます。SymbolDateのインスタンスのみが、この動作をオーバーライドします(後で示します)。

2.4.1.2 オブジェクトをプリミティブに変換するために呼び出されるメソッドはどれですか?

プリミティブへの変換がSymbol.toPrimitiveを介してオーバーライドされていない場合、OrdinaryToPrimitive()は次の2つのメソッドのいずれか、または両方とも呼び出します。

次のコードは、それがどのように機能するかを示しています。

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

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

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

プロパティキーSymbol.toPrimitiveを持つメソッドは、通常のプリミティブへの変換をオーバーライドします。これは標準ライブラリでは2回だけ行われます。

2.4.1.3 Date.prototype[Symbol.toPrimitive]()

これは、日付がプリミティブ値に変換される方法です。

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);
  };

デフォルトのアルゴリズムとの唯一の違いは、'default''string'になり('number'ではなく)、hint'default'に設定する演算を使用した場合に観察できます。

これは、ToString()のJavaScriptバージョンです。

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);
  }
}

この関数が、オブジェクトに対してプリミティブ結果を文字列に変換する前に、中間ステップとしてToPrimitive()を使用している点に注目してください(A行)。

ToString()は、String()の動作と興味深い点で異なります。argumentがシンボルである場合、前者はTypeErrorをスローしますが、後者はスローしません。なぜでしょうか?シンボルのデフォルトは、文字列への変換で例外をスローすることです。

> const sym = Symbol('sym');

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

そのデフォルトは、String()Symbol.prototype.toString()でオーバーライドされています(両方とも次のセクションで説明します)。

> 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()は、関数呼び出しを介して呼び出されるか、newを介して呼び出されるかによって動作が異なります。new.targetを使用して、この2つを区別します。

これらは、ヘルパー関数StringCreate()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()

String()に加えて、.toString()メソッドを使用してシンボルを文字列に変換することもできます。その仕様は以下のとおりです。

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

.toString()のデフォルトの仕様は以下のとおりです。

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 + ']';
};

この操作は、プレーンオブジェクトを文字列に変換する場合に使用されます。

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

デフォルトでは、クラスのインスタンスを文字列に変換する場合にも使用されます。

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

通常、MyClassの文字列表現を設定するために.toString()をオーバーライドしますが、「object」の後の文字列内の角括弧で囲まれた部分も変更できます。

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

.toString()のオーバーライドバージョンとObject.prototypeの元のバージョンを比較すると興味深いです。

> ['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()は、とりわけ、角括弧演算子で使用されます。その動作は以下のとおりです。

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

繰り返しますが、プリミティブ型で操作する前に、オブジェクトはプリミティブ型に変換されます。

ToNumeric()は、とりわけ、乗算演算子(*)で使用されます。その動作は以下のとおりです。

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

ToNumber()は、以下のとおり動作します。

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);
  }
}

ToNumber()の構造は、ToString()の構造と似ています。

2.5 強制変換を行う操作

2.5.1 加算演算子(+

JavaScriptの加算演算子の仕様は以下のとおりです。

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)
}

このアルゴリズムの手順

2.5.2 抽象等値比較(==

/** 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;
}

次の操作はここでは示されていません。

JavaScriptの型強制変換の仕組みを詳しく見てきたので、型変換に関する用語の簡単な用語集で締めくくりましょう。

[出典:Wikipedia]