Оригинал: Type coercion in JavaScript, автор Dr. Axel Rauschmayer

Что такое преобразование типов?

Каждая операция (функция, оператор и т. п.) ожидают, что их входящие параметры будут иметь определенный тип. Если тип параметра не соответствует ожидаемому, есть два варианта развития событий:

  • явное приведение типов к нужным. Например, для умножения чисел, записанных в виде строк:
Number('3') * Number('2') // 6
  • неявное преобразование:
'3' * '2' // 6

Это и называется приведением типов (coercion).

Приведение типов в JavaScript: coercion и casting

Изначально в JavaScript не было исключений, поэтому для большинства операций язык использует принудительное неявное приведение типов и ошибочные значения:

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

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

Тем не менее есть некоторые кейсы (особенно при использовании новых фич), в которых выбрасывается исключение, если тип аргумента не соответствует ожидаемому:

  • Доступ к свойствам для null и undefined
undefined.prop
// TypeError: Cannot read property 'prop' of undefined 

null.prop
// TypeError: Cannot read property 'prop' of null 

'prop' in null 
// TypeError: Cannot use 'in' operator to search for 'prop' in null
  • Использование символов
6 / Symbol() 
// TypeError: Cannot convert a Symbol value to a number
  • Смешивание в одной операции обычных чисел и bigint
6 / 3n 
// TypeError: Cannot mix BigInt and other types, use explicit conversions
  • Вызов в качестве функции (или через new) значений, которые не поддерживают подобной функциональности
123() 
// TypeError: 123 is not a function 

(class {})() 
// TypeError: Class constructor  cannot be invoked without 'new' 

new 123 
// TypeError: 123 is not a constructor 

new (() => {}) 
// TypeError: (intermediate value) is not a constructor
  • Изменение read-only свойств (в strict mode)
'abc'.length = 1 
// TypeError: Cannot assign to read only property 'length' of string 'abc' 

Object.freeze({prop:3}).prop = 1 
// TypeError: Cannot assign to read only property 'prop' of object '#<Object>'

Как работает внутреннее преобразование типов в спецификации

В этом разделе описаны самые важные внутренние функции преобразований, определенные в спецификации ECMAScript.

Например, в TypeScript вы можете написать так:

function multiply(leftValue: number, rightValue: number) {
  // ···
}

По спецификации это будет выглядеть примерно так:

function multiply(leftValue, rightValue) {
  let lnum = ToNumeric(leftValue);
  let rnum = ToNumeric(rightValue);
  // ···
}

Преобразование к примитивам и объектам

Где бы ни ожидались примитивные значения или объекты, для их преобразования используются следующие операции:

  • ToBoolean()
  • ToNumber()
  • ToBigInt()
  • ToString()
  • ToObject()

В JavaScript есть аналоги для них:

Boolean(0)
// false

Boolean(1)
// true

Number('123')
// 123

Из-за появления нового типа bigint в дополнение к number, спецификация также определяет операцию ToNumeric(), которая должна использоваться там, где раньше была ToNumber().

Преобразование к числовым типам

  • ToNumeric() используется там, где ожидается либо число (number), либо bigint.
  • ToInteger(x) используется для чисел без дробной части.
    • Используется операцияToNumber(x), а затем удаляется дробная часть (похоже на Math.trunc()).
  • ToInt32(), ToUint32() приводят числа к 32-битовому целому формату. Используются побитовыми операторами (см. таблицу ниже).
    • ToInt32(): число со знаком в интервале [−231, 231−1]
    • ToUint32(): число без знака (unsigned) в интервале [0, 231−1]

Приведение типов в побитовых операторах для чисел (для типа Number). Для BigInt количество битов не ограничивается.

ОперацияЛевый операндПравый операндРезультат
<<ToInt32()ToUint32()Int32
signed >>ToInt32()ToUint32()Int32
unsigned >>>ToInt32()ToUint32()Uint32
&^|ToInt32()ToUint32()Int32
~ToInt32()Int32

Преобразование к ключам свойств

ОперацияToPropertyKey() возвращает строку или символ. Она используется в следующих местах:

  • Оператор квадратных скобок []
  • Вычисляемые ключи свойств в литералах объекта
  • Левый операнд оператора in
  • Object.defineProperty(_, P, _)
  • Object.fromEntries()
  • Object.getOwnPropertyDescriptor()
  • Object.prototype.hasOwnProperty()
  • Object.prototype.propertyIsEnumerable()
  • Некоторые методы Reflect

Преобразование к индексам массива

  • ToLength() используется (напрямую) в основном  для строковых индексов.
    • Является вспомогательной функцией для ToIndex().
    • Диапазон результатов l: 0 ≤ l ≤ 253−1
  • ToIndex() используется для индексов TypedArray.
    • Основное отличие от ToLength(): выбрасывает исключение, если значение аргумента находится вне диапазона.
    • Диапазон результатов: i: 0 ≤ i ≤ 253−1
  • ToUint32() используется для индексов массивов.
    • Диапазон результатов i: 0 ≤ i < 232−1 (верхняя граница исключается, чтобы оставить место для .length).

Преобразование к элементам Typed Array

Когда вы устанавливаете значение для элемента типизированного массива, используется одна из следующих функций преобразования:

  • ToInt8()
  • ToUint8()
  • ToUint8Clamp()
  • ToInt16()
  • ToUint16()
  • ToInt32()
  • ToUint32()
  • ToBigInt64()
  • ToBigUint64()

Выражение алгоритмов спецификации в JavaScript

Дальше будет приведено несколько алгоритмов из спецификации, реализованных на JavaScript.

  • Спецификация: If Type(value) is String
    • JavaScript: if (TypeOf(value) === 'string') (описание функции ниже)
  • Спецификация: If IsCallable(method) is true
    • JavaScript: if (IsCallable(method)) (описание функции ниже)
  • Спецификация: Let numValue be ToNumber(value)
    • JavaScript: let numValue = Number(value)
  • Спецификация: Let isArray be IsArray(0)
    • JavaScript: let isArray = Array.isArray(O)
  • Спецификация: If O has a [[NumberData]] internal slot
    • JavaScript: if ('__NumberData__' in O)
  • Спецификация: Let tag be Get(O, @@toStringTag)
    • JavaScript: let tag = O[Symbol.toStringTag]
  • Спецификация: Return the string-concatenation of «[object», tag, and «]»
    • JavaScript: return '[object ' + tag + ']'

Некоторые вещи опущены, например, условные обозначения ReturnIfAbrupt ? и !.

// Улучшенная версия 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';
}

Примеры алгоритмов преобразования

ToPrimitive()

Операция ToPrimitive() — это промежуточный шаг для многих алгоритмов преобразования (некоторые из которых мы позже рассмотрим). Она конвертирует произвольные значения в примитивные.

ToPrimitive() часто используется в спецификации, как как многие операции работают только с примитивными значениями. Например, мы можем использовать оператор плюс (+) для сложения чисел (number) или конкатенации строк (string), но не для конкатенации массивов (Array).

JavaScript-версия ToPrimitive() выглядит примерно так:

function ToPrimitive(input: any,
  hint: 'default' | 'string' | 'number' = '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;
    }
  }

Параметр hint может принимать одно из трех значений:

  • ‘number’ — если возможно, input должен быть конвертирован в число
  • ‘string’ — если возможно, input должен быть конвертирован в строку
  • ‘default’ — нет предпочтений, во что конвертировать

ToPrimitive() позволяет объектам переопределять способ преобразования в примитив с помощью Symbol.toPrimitive. Если объект не использует переопределение, будет применен метод 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();
}

Методы, используемые в операциях ToPrimitive() и OrdinaryToPrimitive()

Для преобразования в примитивные значения используются три метода объектов:

  • toString(), если параметр hint указывает, что результат предпочтительно должен быть строкой.
  • valueOf() — если мы хотели бы получить число.
  • Symbol.toPrimitive — для кастомного преобразования. В стандартной библиотеке этот метод используется дважды:
    • Symbol.prototype[@@toPrimitive](hint). Возвращает обернутый символ. Существует для поддержки двух кейсов: у символов есть метод toString(), который возвращает строку. Экземпляры Symbol не должны быть случайно преобразованы в строки.
    • Date.prototype[@@toPrimitive](hint). Подробно разбирается чуть ниже.

Теперь посмотрим, как параметр hint влияет на выбор того или иного метода:

  • Number() вызывает операцию ToPrimitive() с параметром hint, равным 'number':
    > Number({valueOf() {return 1}, toString() {return 'a'}})
    1
  • String() вызывает операцию ToPrimitive() с параметром hint, равным 'string':
    String({valueOf() {return 1}, toString() {return 'a'}})
    'a'

Как разные операции устанавливают значение hint в ToPrimitive()?

Вот несколько примеров вызова операции ToPrimitive() другими операциями с разными значениями hint:

  • hint === 'number'. Работать с числами предпочитают следующие операции:
    • ToNumeric()
    • ToNumber()
    • ToBigInt(), BigInt()
    • Абстрактное сравнение (<)
  • hint === 'string'. Эти операции предпочитают строки:
    • ToString()
    • ToPropertyKey()
  • hint === 'default'. А этим операциями все равно, какого типа значение вернется:
    • Нестрогое равенство (==)
    • Оператор сложения (+)
    • new Date(value)value может быть и строкой, и числом.

Значение по умолчанию (default) обрабатывается, как если бы оно было равно 'number'. Только Symbol и Date переопределяют это поведение.

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':

  • == приводит дату к строке, если второй операнд является примитивом, отличным от undefined, null или boolean:
const d = new Date('2222-03-27'); 
d == 'Wed Mar 27 2222 01:00:00 GMT+0100 (Central European Standard Time)' // true
  • Даже если первый операнд оператора + является числом, результат — все равно строка. Это значит, что дата приводится к строке, и происходит конкатенация:
123 + d // '123Wed Mar 27 2222 01:00:00 GMT+0100 (Central European Standard Time)'

ToString() и связанные операции

JavaScript-версия операции 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);
  }
}

Обратите внимание, что здесь используется операция ToPrimitive() — для получения от объекта промежуточного примитивного значения, которое будет конвертировано в строку.

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

String()

function String(value) {
  let s;
  if (value === undefined) {
    s = '';
  } else {
    if (new.target === undefined && TypeOf(value) === 'symbol') { // (A)
      return SymbolDescriptiveString(value);
    }
    s = ToString(value);
  }
  if (new.target === undefined) {
    // Function call
    return s;
  }
  // New call
  return StringCreate(s, new.target.prototype); // simplified!
}

Мы видим, что String() ведет себя по-разному, если вызывается как функция и если вызывается как конструктор (new).

Вспомогательные функции StringCreate() и SymbolDescriptiveString() выглядят следующим образом:

function StringCreate(value, prototype) {
  // Create a new String instance that has the given 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+')';
}

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

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]');

Вы можете изменять, что следует за словом object в квадратных скобках:

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

Если вы напрямую вызываете Object.prototype.toString, вы можете получить доступ к переопределенному поведению:

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

String(['a', 'b'])
//'a,b'

ToPropertyKey()

Операция ToPropertyKey() среди прочего используется оператором квадратных скобок ([]):

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

Опять же, здесь предварительно объекты преобразуются в примитивные значения.

ToNumeric() и связанные операции

Операция ToNumeric() среди прочего используется оператором умножения (*).

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

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().

Операции с преобразованием типов

Оператор сложения (+)

Вот так описан в спецификации 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)
}

Шаги этого алгоритма:

  • Оба операнда конвертируются в примитивные значения.
  • Если один из операндов является строкой, то оба преобразуются в строки, и возвращается результат их конкатенации.
  • Иначе оба оператора преобразуются в числа, и возвращается результат их сложения.

Нестрогое равенство (==)

Нестрогое равенство типов в JavaScript
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;
}

Следующие вспомогательные операции в статье не описаны:

  • strictEqualityComparison()
  • StringToBigInt()
  • isSameMathematicalValue()

Глоссарий

Разобравшись немного с приведением типов в JavaScript, давайте составим небольшое резюме в виде словарика терминов:

  • При преобразовании типов (type conversion) мы хотим, чтобы выходное значение имело некоторый заданный тип. Если входное значение уже имеет этот тип, то оно просто возвращается без изменений. В противном случае, оно будет сконвертировано по определенным правилам к желаемому типу.
  • Явное преобразование типов (explicit type conversion) означает, что программист использует операцию (функцию или оператор) для запуска преобразования типов. Явное преобразование может быть:
    • Проверенным (Checked): Если преобразование невозможно, выбрасывается исключение.
    • Непроверенным (Unchecked): Если преобразование невозможно, возвращается ошибка.
  • Приведение типов (type coercion) — это неявное преобразование типов. Операции автоматически преобразуют свои аргументы в те типы, которые им нужны. Такое преобразование может быть и проверенным, и непроверенным.

0 комментариев

Оставить комментарий

*Доступные HTML-теги: a, abbr, blockquote, code, pre, del, i, em, strong, b, strike
*Не будет опубликован