toString: Великий и Ужасный
Функция toString в языке JavaScript наверно самая «неявно» обсуждаемая как среди самих js-разработчиков, так и среди внешних наблюдателей. Она — причина многочисленных шуток и мемов про многие подозрительные арифметические операции, преобразования, вводящие в ступор [object Object]'ы. Уступает, возможно, лишь удивлениям при работе с float64.
Интересные случаи, которые мне приходилось наблюдать, использовать или преодолевать, мотивировали меня написать настоящий разбор полетов. Мы галопом проскочим по спецификации языка и на примерах разберем неочевидные особенности toString.
Если вы ожидаете полезного и достаточного руководства, то вам больше подойдет этот, этот и тот материалы. Если же ваше любопытство все таки преобладает над прагматичностью, то прошу под кат.
Все что нужно знать
Функция toString — свойство объекта-прототипа Object, простыми словами — его метод. Используется при строковом преобразовании объекта и по-хорошему должна возвращать примитивное значение. Свои реализации также имеют объекты-прототипы: Function, Array, String, Boolean, Number, Symbol, Date, RegExp, Error. Если вы реализуете свой объект-прототип (класс), то хорошим тоном будет определить для него и toString.
JavaScript — язык со слабой системой типов:, а значит, позволяет нам смешивать разные типы, выполняет многие операции неявно. В преобразованиях toString работает в паре с valueOf, чтобы свести объект к нужному для операции примитиву. Например, оператор сложения оборачивается конкатенацией при наличии среди операторов хотя бы одной строки. Некоторые стандартные функции языка перед своей работой приводят аргумент к строке: parseInt, decodeURI, JSON.parse, btoa и проч.
Про неявное приведение типов сказано и высмеяно уже довольно много. Мы же рассмотрим реализации toString ключевых объектов-прототипов языка.
Object.prototype.toString
Если мы обратимся к соответствующему разделу спецификации, то обнаружим, что основная задача дефолтного toString — это получить так называемый tag для конкатенации в результирующую строку:
"[object " + tag + "]"
Для этого:
- Происходит обращение к внутреннему символу toStringTag (или псевдо-свойству [[Class]] в старой редакции): его имеют многие встроенные объекты-прототипы (Map, Math, JSON и другие).
- Если таковое отсутствует или не строка, то осуществляется перебор ряда других внутренних псевдо-свойств и методов, сигнализирующих о типе объекта: [[Call]] для Function, [[DateValue]] для Date и прочее.
- Ну и если совсем ничего, то tag — это «Object».
Болеющие рефлексией сразу отметят возможность получить тип объекта простой операцией (не рекомендуется спецификацией, но можно):
const getObjT = obj => Object.prototype.toString.call(obj).match(/\[object\s(\w+)]/)[1];
Особенностью дефолтного toString является то, что он работает с любым значением this. Если это примитив, то он будет приведен к объекту (null и undefined проверяются отдельно). Никаких TypeError:
[Infinity, null, x => 1, new Date, function*(){}].map(getObjT);
> ["Number", "Null", "Function", "Date", "GeneratorFunction"]
Как это может пригодиться? Например, при разработке инструментов динамического анализа кода. Имея импровизированный пул используемых в процессе работы приложения переменных, в run-time можно собирать полезную однородную статистику.
У этого подхода есть один существенный недостаток: пользовательские типы. Не трудно догадаться, что для их экземпляров мы просто получим «Object».
Кастомный Symbol.toStringTag и Function.name
ООП в JavaScript базируется на прототипах, а не на классах (как например в Java), и готового метода getClass () у нас нет. Решить возникшую проблему поможет явное определение символа toStringTag для пользовательского типа:
class Cat {
get [Symbol.toStringTag]() {
return 'Cat';
}
}
или в прототипном стиле:
function Dog(){}
Dog.prototype[Symbol.toStringTag] = 'Dog';
Есть альтернативное решение через read-only свойство Function.name, которое пока не является частью спецификации, но поддерживается большинством браузеров. Каждый экземпляр объекта-прототипа/класса имеет ссылку на функцию-конструктор, с помощью которой он был создан. А значит мы можем узнать название типа:
class Cat {}
(new Cat).constructor.name
< 'Cat'
или в прототипном стиле:
function Dog() {}
(new Dog).constructor.name
< 'Dog'
Разумеется, это решение не работает для объектов, созданных с помощью анонимной функции («anonymous») или Object.create (null), а также для примитивов без объекта-обертки (null, undefined).
Таким образом, для надежной манипуляции типами переменных стоит комбинировать известные приемы, в первую очередь отталкиваясь от решаемой задачи. В подавляющем большинстве случаев достаточно typeof и instanceof.
Function.prototype.toString
Мы немного отвлеклись, но в результате добрались до функций, у которых есть свой интересный toString. Для начала взглянем на следующий код:
(function() { console.log('(' + arguments.callee.toString() + ')()'); })()
Многие наверно догадались, что это пример куайна. Если загрузить скрипт с таким содержимым в тело страницы, то в консоль будет выведена точная копия исходного кода. Это происходит благодаря вызову toString от функции arguments.callee.
Используемая реализация toString объекта-прототипа Function возвращает строковое представление исходного кода функции, сохраняя используемый при ее определении синтаксис: FunctionDeclaration, FunctionExpression, ClassDeclaration, ArrowFunction и проч.
Например, мы имеем стрелочную функцию:
const bind = (f, ctx) => function() {
return f.apply(ctx, arguments);
}
Вызов bind.toString () вернет нам строковое представление ArrowFunction:
"(f, ctx) => function() {
return f.apply(ctx, arguments);
}"
А вызов toString от обернутой функции — это уже строковое представление FunctionExpression:
"function() {
return f.apply(ctx, arguments);
}"
Этот пример с bind не случаен, так как у нас есть готовое решение с привязкой контекста Function.prototype.bind, и касательно нативных bound functions есть особенность работы Function.prototype.toString с ними. В зависимости от реализации может быть получено представление как самой обернутой функции, так и оборачиваемой (target) функции. V8 и SpiderMonkey последних версий хрома и ff:
function getx() { return this.x; }
getx.bind({ x: 1 }).toString()
< "function () { [native code] }"
Таким образом, стоит проявлять осторожность с нативно-декорируемыми функциями.
Практика использования f.toString
Вариантов использования рассматриваемого toString очень много, но настоятельно только в качестве инструмента метапрограммирования или дебага. Наличие подобного в бизнес-логике типового приложения рано или поздно приведет к неподдерживаемому разбитому корыту.
Самое простое, что приходит на ум — это определение длины функции:
f.toString().replace(/\s+/g, ' ').length
Расположение и количество пробельных символов результата toString отдается спецификацией на откуп конкретной реализации, поэтому для чистоты мы предварительно убираем лишнее, приводя к общему виду. К слову, в старых версиях движка Gecko у функции был специальный параметр indentation, помогающий с форматированием отступов.
Сразу на ум приходит и определение имен параметров функции, что может пригодится для рефлексии:
f.toString().match(/^function(?:\s+\w+)?\s*\(([^\)]+)/m)[1].split(/\s*,\s*/)
Это коленочное решение подойдет для синтаксиса FunctionDeclaration и FunctionExpression. Если потребуется более развернутое и точное, то рекомендую обратиться за примерами к исходному коду вашего любимого фреймворка, который наверняка имеет под капотом какое-нибудь внедрение зависимостей, основанное именно на именах объявленных параметров.
Опасный и интересный вариант переопределения функции через eval:
const sum = (a, b) => a + b;
const prod = eval(sum.toString().replace(/\+(?=\s*(?:a|b))/gm, '*'));
sum(5, 10)
< 15
prod(5, 10)
< 50
Зная структуру исходной функции, мы создали новую, подменив используемый в ее теле перед аргументами оператор сложения — умножением. В случае программно-генерируемого кода или отсутствия интерфейса расширения функции это может быть волшебным образом полезно. Например, если вы исследуете некоторую математическую модель, подбирая подходящую функцию, играясь с операторами и коэффициентами.
Более практичное использование — это компиляция и дистрибьюция шаблонов. Многие реализации шаблонизаторов компилируют исходный текст шаблона и предоставляют функцию от данных, которая уже формирует конечный HTML (или другое). Далее на примере функции _.template:
const helloJst = "Hello, <%= user %>"
_.template(helloJst)({ user: 'admin' })
< "Hello, admin"
А что, если компиляция шаблона требует аппаратных ресурсов или клиент ну очень тонкий? В этом случае мы можем компилировать шаблон на серверной стороне и отдавать клиентам уже не текст шаблона, а строковое представление готовой функции. Тем более, вам не нужно грузить на клиент библиотеку шаблонизатора.
const helloStr = _.template(helloJst).toString()
helloStr
< "function(obj) {
obj || (obj = {});
var __t, __p = '';
with (obj) {
__p += 'Hello, ' +
((__t = ( user )) == null ? '' : __t);
}
return __p
}"
Теперь нам необходимо выполнить этот код на клиенте перед использованием. Чтобы при компиляции не было SyntaxError из-за синтаксиса FunctionExpression:
const helloFn = eval(helloStr.replace(/^function\(obj\)/, 'obj=>'));
или так:
const helloFn = eval(`const f = ${helloStr};f`);
Или как вам больше нравится. В любом случае:
helloFn({ user: 'admin' })
< "Hello, admin"
Это может быть не самая лучшая практика компиляции шаблонов на серверной стороне и их дальнейшего распространения на клиенты. Просто пример с использованием связки Function.prototype.toString и eval.
Наконец, старая задача про определение имени функции (до появления свойства Function.name) через toString:
f.toString().match(/function\s+(\w+)(?=\s*\()/m)[1]
Разумеется, это хорошо работает в случае синтаксиса FunctionDeclaration. Более интеллектуальное решение потребует хитрого регулярного выражения или использования сопоставления с образцом.
В интернетах полно интересных решений на базе Function.prototype.toString, достаточно лишь поинтересоваться. Делитесь своим опытом в комментариях: очень интересно.
Array.prototype.toString
Реализация toString объекта-прототипа Array является обобщенной и может быть вызвана для любого объекта. Если объект имеет метод join, то результатом toString будет его вызов, иначе — Object.prototype.toString.
Array, логично, имеет метод join, который конкатенирует строковое представление всех своих элементов через переданный в качестве параметра separator (по умолчанию это запятая).
Допустим, нам надо написать функцию, сериализующую список своих аргументов. Если все параметры — примитивы, то во многих случаях мы можем обойтись без JSON.stringify:
function seria() {
return Array.from(arguments).toString();
}
или так:
const seria = (...a) => a.toString();
Только помните, что строка '10' и число 10 будут сериализованы одинаково. В задаче про кратчайший мемоизатор на одном из этапом использовалось это решение.
Нативный джойн элементов массива работает через арифметический цикл от 0 до length и не фильтрует отсутствующие элементы (null и undefined). Вместо этого происходит конкатенация с separator. Это приводит к следующему:
const ar = new Array(1000);
ar.toString()
< ",,,...,,," // 1000 times
Поэтому, если вы по той или иной причине добавляете в массив элемент с большим индексом (например, это сгенерированный натуральный id), ни в коем случае не джойните и, соответственно, не приводите к строке без предварительной подготовки. Иначе могут быть последствия: Invalid string length, out of memory или просто повисший скрипт. Используйте функции объекта Object values и keys, чтобы итерироваться только по собственным перечислимым свойствам объекта:
const k = [];
k[2**10] = 1;
k[2**20] = 2;
k[2**30] = 3;
Object.values(k).toString()
< "1,2,3"
Object.keys(k).toString()
< "1024,1048576,1073741824"
Но гораздо лучше избегать подобного обращения с массивом: скорее всего в качестве хранилища вам подошел бы простой key-value объект.
К слову, такая же опасность есть и при сериализации через JSON.stringify. Только еще серьезнее, так как пустые и неподдерживаемые элементы представлены уже как «null»:
const ar = new Array(1000);
JSON.stringify(ar);
< "[null,null,null,...,null,null,null]" // 1000 times
Завершая раздел хотелось бы напомнить, что вы можете определить для пользовательского типа свой метод join и вызывать Array.prototype.toString.call в качестве альтернативного приведения к строке, но я сомневаюсь, что это имеет какое-то практическое применение.
Number.prototype.toString и parseInt
Одна из моих любимых задач для js-викторин — Что вернет следующий вызов parseInt?
parseInt(10**30, 2)
Первое, что делает parseInt — это неявное приведение аргумента к строке через вызов абстрактной функции ToString, которая в зависимости от типа аргумента выполняет нужную ветку приведения. Для типа number осуществляется следующее:
- Если значение равно NaN, 0 или Infinity, то вернуть соответствующую строку.
- Иначе алгоритм возвращает наиболее человеко-удобную запись числа: в десятичной или экспоненциальной форме.
Я не буду дублировать здесь алгоритм определения предпочтительной формы, только отмечу следующее: если количество цифр числа в десятичной записи превышает 21, то будет выбрана экспоненциальная форма. А это значит, что в нашем случае parseInt работает не с »100…000», а с »1e30». Поэтому ответ совсем не ожидаемый 2^30. Кто знает природу этого магического числа 21 — пишите!
Далее parseInt смотрит на используемое основание системы счисления radix (по умолчанию 10, у нас — 2) и проверяет символы полученной строки на совместимость с ним. Встретив 'e', отсекает весь хвост, оставляя только »1». Результатом же будет целое число, полученное путем перевода из системы с основанием radix в десятичную — в нашем случае, это 1.
Обратная процедура:
(2**30).toString(2)
Здесь происходит вызов функции toString от объекта-прототипа Number, который использует тот же алгоритм приведения number к строке. Он тоже имеет опциональный параметр radix. Только он бросает RangeError для невалидного значения (должно быть целое от 2 до 36 включительно), тогда как parseInt возвращает NaN.
Стоит помнить о верхней границе системы счисления, если планируете реализовывать экзотическую хэш-функцию: вам может не подойти этот toString.
Задачка, чтобы отвлечься на минутку:
'3113'.split('').map(parseInt)
Что вернет и как исправить?
Обделенное вниманием
Мы рассмотрели toString далеко не всех даже нативных объектов-прототипов. Отчасти, потому что лично мне не приходилось попадать с ними в передряги, да и интересного в них не много. Также мы не затронули функцию toLocaleString, так как про нее хорошо бы поговорить отдельно. Если я все таки что-то зря обделил вниманием, упустил из виду или недопонял — обязательно пишите!
Призыв к бездействию
Приведенные мною примеры ни в коем случае не являются готовыми рецептами — только пищей для размышлений. Кроме того я нахожу бессмысленным и немного бестолковым обсуждать подобное на технических собеседованиях: для этого есть вечные темы про замыкания, хойстинг, event loop, паттерны модуль/фасад/медиатор и «конечно» вопросы про [используемый фреймворк].
Настоящая статья получилась сборной солянкой, и я надеюсь вы нашли что-то интересное для себя. PS Язык JavaScript — удивителен!
Бонус
Подготавливая настоящий материал к публикации, я пользовался Google Переводчиком. И совершенно случайно обнаружил занимательный эффект. Если выбрать перевод с русского на английский, ввести «toString» и начать его стирать через клавишу Backspace, то мы будем наблюдать:
Такая вот ирония! Думаю, я далеко не первый такой, но на всякий случай отправил им скриншот со сценарием воспроизведения. Выглядит как безобидный self-XSS, поэтому и делюсь.