Pull to refresh

toString: Великий и Ужасный

Reading time 10 min
Views 39K

image


Функция 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 + "]"

Для этого:


  1. Происходит обращение к внутреннему символу toStringTag (или псевдо-свойству [[Class]] в старой редакции): его имеют многие встроенные объекты-прототипы (Map, Math, JSON и другие).
  2. Если таковое отсутствует или не строка, то осуществляется перебор ряда других внутренних псевдо-свойств и методов, сигнализирующих о типе объекта: [[Call]] для Function, [[DateValue]] для Date и прочее.
  3. Ну и если совсем ничего, то 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 осуществляется следующее:


  1. Если значение равно NaN, 0 или Infinity, то вернуть соответствующую строку.
  2. Иначе алгоритм возвращает наиболее человеко-удобную запись числа: в десятичной или экспоненциальной форме.

Я не буду дублировать здесь алгоритм определения предпочтительной формы, только отмечу следующее: если количество цифр числа в десятичной записи превышает 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, то мы будем наблюдать:


bonus


Такая вот ирония! Думаю, я далеко не первый такой, но на всякий случай отправил им скриншот со сценарием воспроизведения. Выглядит как безобидный self-XSS, поэтому и делюсь.

Tags:
Hubs:
+16
Comments 6
Comments Comments 6

Articles