Про приведение типов в JS и магию. Часть 2

Что еще надо знать в 2019 году

«Где отсутствует точное знание, там действуют догадки,
а из десяти догадок девять — ошибки»

Этот материал будет полезен тем, кто планирует проходить собеседования, так и тем, кто уже работает с JS и хочет лучше его понять (и простить).

В предыдущей статье про приведение типов в JS мы говорили о том, что в JS нет магии и неопределенности. Все подчиняется правилам и если вы знаете базовые правила, то у вас не будет проблем с JavaScript, вам не надо заучивать фокусы вида {}+{} и для вас нет никакого “WAT?!”

В первой части мы затронули тему методов для приведения типов valueOf и toString, но не развили. Буквально еще недавно было достаточно знать про эти два метода. И вроде бы было все просто, но…

toString

Если в объекте реализован метод toString, который возвращает примитив, то для преобразования используется этот метод, иначе вызывается метод из родительского прототипа (ну не совсем так, ниже будет объяснено как на самом деле).

Метод toString не обязан возвращать именно строку, результатом toString может быть любой примитив. Поэтому этот метод называется не «преобразование к строке», а «строковое преобразование», так как не факт что на выходе будет строка:

var obj = { toString() { return "200" } }
obj + 1 // '2001'
obj + 'a' // '200a'
// и совсем другой результат
var obj = { toString() { return 200 } }
obj + 1 // 201
obj + 'a' // '200a'

Это первая ошибка, которая встречается в работе и на собеседовании: toString может возвращать не только строку!

Но!

var obj = {  toString() { return [] } }
TypeError: Cannot convert object to primitive value

Только примитивы, никаких объектов!

valueOf

valueOf — численное преобразование. Используется для численного преобразования объекта и вызывается всегда перед toString. Если его нет, ищется toString.

Метод valueOf обязан возвращать примитив, иначе его результат будет проигнорирован — это если простым языком. Если более точно, то тут немного сложнее:

  • ищется vlueOf
  • если valueOf не найден или вернул не примитив, ищется toString
  • если найден toString — вызывается
  • если toString не найден, ищется valueOf в parent.prototype и все повторяется…

Но!

(тут ошибка в комментариях — перезалить фотку)

И не забываем, что после вызова методов для преобразования примитивов, если левый и правый операнды все еще разных типов, то начинают работать механизмы, описанные в предыдущей статье: Приведение типов в JS. Если, к примеру, результат этих функций число, а вы складываете его с boolean, то преобразования будут продолжаться.

Фишка valueOf в том, что его нет почти ни у кого и у всех по дефолту реализован toString. Есть только одно исключение, у кого реализован valueOf и этот метод вызывается независимо от toString.

Исключения

По историческим причинам объект Date является исключением. Дело в том, что у большинства объектов по дефолту не реализован valueOf и вызывается toString. Но у Date реализованы и toString, и valueOf:

''+new Date   // 'Thu Mar 07 2018 21:14:50 GMT+0300'
+new Date // 1551982490848

Исключение тут еще в том, что сложение со строкой вызывает toString, хотя согласно логике должен вызываться всегда valueOf, что и выделяет объект Date среди прочих. И опять, это не магия, это правило (точнее исключение), которое надо запомнить и этого будет достаточно…

— Или не исключение? Можете создать объект похожий по поведению на Date?

Мне сложно сказать, как был реализован объект Date раньше, но сегодня его поведение можно повторить. Решение этой задачи будет чуть ниже.

— А как работают valueOf и toString при строгих равенствах? Ммм?

— Отличный вопрос!

console.log(
1 == { valueOf: _=>console.log('detect') }
)
// print 2 lines: detect and false
// and if:
console.log(
1 === { valueOf: _=>console.log('detect') }
)
// print 1 line: false

При строгих сравнениях магические методы не вызываются. Запомнили, идем дальше. Хоть это и попало в блок “исключения”, но это не исключение. Это правило.

Symbol

С приходом в JS такого типа как Symbol, у нас появились и новый тип, и новые магические методы. Если раньше на вопрос “Сколько типов в JS” вы отвечали:

JavaScript определяет 6 типов данных:

  • 5 типов данных примитивы: Number, String, Boolean и два специальных типа Undefined и Null (при этом typeof null = “object”)
  • И тип Object.

То в 2019 этот ответ уже давно является ошибочным и на него надо отвечать так: современный стандарт ECMAScript6+ определяет 7 типов данных: все что сказали выше плюс еще тип Symbol.

Symbol — это новый примитивный тип данных, который служит для создания уникальных идентификаторов. Символ получается с помощью функции Symbol:

let symbol = Symbol();
typeof symbol === 'symbol'

— Если это примитив, то его так же можно вернуть из toString и valueOf, м?

— О, интересный вопрос!

const obj = {
toString() {console.log('Call toString')
return Symbol.for("lol")
}
}
  +obj  //TypeError: Cannot convert a Symbol value to a number
''+obj // TypeError: Cannot convert a Symbol value to a string

Хмм. Чет ломается логика. А ты говорил… Ха ха, жабаскрипт косячный!

То что оно не конвертится, не значит что нельзя вернуть. Доказательство:

obj == Symbol.for("lol") // true

С valueOf все тоже самое и все работает согласно правилам.

Well-known Symbols

Существует так же глобальный реестр символов, который позволяет иметь общие глобальные символы, которые можно получить из реестра по имени. Для чтения (или создания, при отсутствии) глобального символа служит вызов метода Symbol.for(name).

Особенность символов в том, что если в объект записать свойство-символ, то оно не участвует в итерациях:

const obj = {
foo: 123,
bar: 'abc',
[Symbol.for('obj.wow')]: true
};
Object.keys(obj); // foo, bar
obj[Symbol.for('obj.wow')]; // true

Символы активно используются внутри самого стандарта ES. Есть много системных символов, их список есть в спецификации, в таблице Well-known Symbols. Для краткости символы принято обозначать как “@@name”, а доступны они как свойства объектаSymbol.

Многие уже успели познакомиться сSymbol.iterator

К примеру, хотим так:

const obj = { foo: 123, bar: 'abc' };
for (let v of obj) v; // TypeError: obj is not iterable

Но не можем. Но если хочется, то можем:

const obj = {
foo: 123,
bar: 'abc',
[Symbol.iterator]() {
const values = Object.values(this);
return { // Iterator object
next: ()=>({
done : 0 === values.length,
value: values.pop(),
})
}
}
}
for (let v of obj) console.log(v);

Громоздко, поэтому мы можем создать класс вида:

class IObject {
constructor(obj) {
for (let k in obj) {
if (!obj.hasOwnProperty(k)) continue;
this[k] = obj[k]
}
}
    [Symbol.iterator]() {
const values = Object.values(this);
return { // Iterator interface
next: ()=>({
done : 0 === values.length,
value: values.pop(),
})
}
}
}
const obj = new IObject({
foo: 123 ,
bar: 'abc',
});
for (let v of obj) console.log(v);

Мы немного отвлеклись, речь не про итераторы…

Так вот, в этой таблице мы видим два символа:

  • @@toPrimitive
  • @@toStringTag

Ухты, что за воу? Давайте разбираться...

Symbol.toPrimitive

Это новый “магический” метод, призванный заменить toString и valueOf. Его принципиальное отличие: он принимает аргумент — тип, к которому желательно привести:

const obj = {
[Symbol.toPrimitive](hint) {
return {
number: 100,
string: 'abc',
default: true
}[hint]
}
};

console.log( +obj ); // 100
console.log( obj + 1 ); // 2
console.log( 1 - obj ); // -99
console.log( `${obj}` ); // abc
console.log( obj + '1' ); // true1
console.log( 'a' + obj ); // atrue

Всего доступно 3 типа хинтинга:

"number"
"string"
"default"

Если реализован метод @@toPrimitive, то toString и valueOf не вызываются.

Точнее так, valueOf — алиас на @@toPrimitive, при этом явный вызов будет передавать тип хинтинга default:

console.log( obj.valueOf() ); // true

Если вы явно вызовите toString, то будет вызван метод родителя, @@toPrimitive не будет участвовать.

Но!

const obj = {
toString() { console.log('toString'); return 1 },
valueOf() { console.log('valueOf'); return 1 },
[Symbol.toPrimitive](hint) {
return {
number: 100,
string: 'abc',
default: true
}[hint]
}
};

если явно не вызывать методы toString и valueOf — то они не будут вызываться. Если вызвать явно — то они отработают явно как обычные методы.

Если вы попробуете вернуть не примитив, то будет ошибка вида:

TypeError: Cannot convert object to primitive value

А теперь про задачу, которую спрашивал выше. Можно ли реализовать поведение как у объекта Date? Ответ: да, можно, как раз благодаря новому @@toPrimitive:

const Dollar = {
num: 66.26,
[Symbol.toPrimitive](hint) {
switch (hint) {
case 'number': return this.valueOf();
case 'string': default: return this.toString()
}
},
valueOf() { return this.num },
toString(){ return `1 доллар = ${this.num} рублей на дату ${(new Date).toLocaleString()}` }
};

console.dir( +Dollar);
console.dir('' +Dollar);

console.dir(Dollar.valueOf() );
console.dir(Dollar.toString() );

Мы получим два разных вывода:

66
'1 доллар = 66 рублей на дату 2019-03-08'

Вот и выходит что то, что раньше считалось исключением, сегодня можно считать нормальным поведением, реализованным через @@toPrimitive, которое мы так же можем повторить.

Symbol.toStringTag

Этот well-known symbol, который содержит в себе строку — тег, который выводится при дефолтном вызове toString:

// built-in toStringTag :
Object.prototype.toString.call('foo'); // "[object String]"
Object.prototype.toString.call([1, 2]); // "[object Array]"
Object.prototype.toString.call(3); // "[object Number]"
Object.prototype.toString.call(true); // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null); // "[object Null]"
Object.prototype.toString.call(new Map()); // "[object Map]"
Object.prototype.toString.call(function* () {}); // "[object GeneratorFunction]"
Object.prototype.toString.call(Promise.resolve()); // "[object Promise]"

Вы можете переопределять это свойство для своих классов, чтобы при дебаге вывод был более информативным, но при этом стандартизирован:

class ValidatorClass{get [Symbol.toStringTag](){return 'Validator'}}
let o = new ValidatorClass

console.log(o.toString())
// Выдаст [object Validator] вместо [object Object].

Symbol.toStringTag возвращает всегда строку. Если не строку, то будет проигнорирован и сработает дефолтный Symbol.toStringTag из родителя. Если есть toString, valueOf или Symbol.toPrimitive — их приоритет выше, поэтому не будет срабатывать дефолтный вызов, в котором используется этот таг.

@@toStringTag всегда будет с префиксом [object].

const o = {
foo: 123,
get [Symbol.toStringTag](){ return JSON.stringify(this) }
};

console.log(o+''); // [object {"foo":123}]

Node.js и inspect

Мы могли бы закончить на этом, но все же нам надо еще сказать пару слов про магию в контексте Node.js (в браузерах этого нет).

const obj = {
foo: 123,
inspect() {console.log('Call Inspect');
return 300
}
};
console.log(obj) // ???

До недавнего времени такой код мог взрывать мозг, опять же из-за незнания и особенностей уже API Node.js. Это зарезервированный метод в Node.js. Был зарезервирован, но уже нет. Если вы объявляли метод inspect, то могли удивляться тому, что вываливалось в консоль.

Но сейчас уже все ок, у нас нода 10+ и в ней больше не вызывается метод inspect. Но! Но с приходом символов появилась альтернативная магия. Благодаря символам можно расширять API не боясь поломать пользовательский код, поэтому есть ряд well-known symbols, которые специфичны только для ноды, среди которых:

const obj = {
foo: 123,
[util.inspect.custom]() { return 300 }
};

console.log( obj ); // 300

Да да, все верно, в консоль выводится 300. Прям как в HR анекдоте:

— Зарплата фронтендеров?
— 300!
— Что 300 ?
— А что зарплата фронтендеров?

Разные npm пакеты пользуются этим механизмом, поэтому если хотите увидеть реальное состояние дел, то пользуйтесь console.dir или:

const util = require('util');
util.inspect.defaultOptions.customInspect = false;
const obj = {
foo: 123,
[util.inspect.custom]() { return 300 }
};

console.log( obj );
console.dir( obj );
// В обоих случаях вывод будет одинаковым:
{ foo: 123,
[Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]] }

Вообще некоторые рекомендуют использовать util.inspect для отладки, вместо console.log и/или console.dir, но это спорный вопрос.

Так же util.inspect.custom принимает аргументы (как и console.dir) и вы можете внутри реализовать умный inspect, реагирующий на настройки:

[util.inspect.custom](depth, options) {
/*
depth: 2
options: {
budget: {},
indentationLvl: 0,
seen: [],
stylize: [Function: stylizeWithColor],
showHidden: false,
depth: 2,
colors: true,
customInspect: true,
showProxy: false,
maxArrayLength: 100,
breakLength: 60,
compact: true,
sorted: false,
getters: false }
*/
}

Закругляемся

Уф, вроде бы все. Зачем все это? Это подготовительный этап к следующей статье.

А если про практический смысл: понимая как работает ваш инструмент, вы сможете делать меньше ошибок (и быстрее их отлавливать) даже без использования TypeScript и прочих инструментов (которые иногда создают иллюзию безопасности). Да да, я считаю что лишние транспайлеры и обертки над языком — это дополнительные точки отказа, которые могут привести к проблемам. Если ванильный код вы можете легко отдебажить, то результат работы транспайлеров и прочих постобработок может быть непредсказуемым и содержать в себе ошибки (такое уже было в практике и не раз). Но это уже другая тема для холивара и я ее обязательно наброшу и разовью.


Лайк, хлопок, шер. Подписывайтесь на Телеграм канал. Кстати, следить за обновлениями и прочими материалами от меня можно именно там: @prowebit . В этом канале публикую не только статьи из этого блога, но и различные новости и мысли, которых нет в этом блоге. Подписывайтесь!

𝔾𝕖𝕖𝕜 🄹🄾🄱 — анонимный поиск работы без палева где можно найти новую работу без проблем на текущем месте. Можно создавать как анонимные, так и открытые профили. Только для IT, никакого “левого” стафа. Только релевантные предложения. Скоро будет мега апдейт ;)

New.HR — место где помогают найти работу мечты. Работаем только с отборными вакансиями в сфере IT & Digital. Помогаем кандидатам найти работу по душе. Работодателям — закрыть вакансию быстро и надолго. Умеем закрывать нетривиальные вакансии и работаем с кандидатами, которые не ищут работу.