Главная » Хабрахабр » toString: Великий и Ужасный

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

image

Она — причина многочисленных шуток и мемов про многие подозрительные арифметические операции, преобразования, вводящие в ступор [object Object]'ы. Функция toString в языке JavaScript наверно самая "неявно" обсуждаемая как среди самих js-разработчиков, так и среди внешних наблюдателей. Уступает, возможно, лишь удивлениям при работе с float64.

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

Если же ваше любопытство все таки преобладает над прагматичностью, то прошу под кат. Если вы ожидаете полезного и достаточного руководства, то вам больше подойдет этот, этот и тот материалы.

Все что нужно знать

Используется при строковом преобразовании объекта и по-хорошему должна возвращать примитивное значение. Функция toString — свойство объекта-прототипа Object, простыми словами — его метод. Если вы реализуете свой объект-прототип (класс), то хорошим тоном будет определить для него и toString. Свои реализации также имеют объекты-прототипы: Function, Array, String, Boolean, Number, Symbol, Date, RegExp, Error.

В преобразованиях toString работает в паре с valueOf, чтобы свести объект к нужному для операции примитиву. JavaScript — язык со слабой системой типов: а значит, позволяет нам смешивать разные типы, выполняет многие операции неявно. Некоторые стандартные функции языка перед своей работой приводят аргумент к строке: 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];

Если это примитив, то он будет приведен к объекту (null и undefined проверяются отдельно). Особенностью дефолтного toString является то, что он работает с любым значением this. Никаких TypeError:

[Infinity, null, x => 1, new Date, function*()].map(getObjT);
> ["Number", "Null", "Function", "Date", "GeneratorFunction"]

Например, при разработке инструментов динамического анализа кода. Как это может пригодиться? Имея импровизированный пул используемых в процессе работы приложения переменных, в run-time можно собирать полезную однородную статистику.

Не трудно догадаться, что для их экземпляров мы просто получим "Object". У этого подхода есть один существенный недостаток: пользовательские типы.

Кастомный Symbol.toStringTag и Function.name

Решить возникшую проблему поможет явное определение символа toStringTag для пользовательского типа: ООП в JavaScript базируется на прототипах, а не на классах (как например в Java), и готового метода getClass() у нас нет.

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

В зависимости от реализации может быть получено представление как самой обернутой функции, так и оборачиваемой (target) функции. Этот пример с bind не случаен, так как у нас есть готовое решение с привязкой контекста Function.prototype.bind, и касательно нативных bound functions есть особенность работы Function.prototype.toString с ними. 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

К слову, в старых версиях движка Gecko у функции был специальный параметр indentation, помогающий с форматированием отступов. Расположение и количество пробельных символов результата toString отдается спецификацией на откуп конкретной реализации, поэтому для чистоты мы предварительно убираем лишнее, приводя к общему виду.

Сразу на ум приходит и определение имен параметров функции, что может пригодится для рефлексии:

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

Если объект имеет метод join, то результатом toString будет его вызов, иначе — Object.prototype.toString. Реализация toString объекта-прототипа Array является обобщенной и может быть вызвана для любого объекта.

Array, логично, имеет метод join, который конкатенирует строковое представление всех своих элементов через переданный в качестве параметра separator (по умолчанию это запятая).

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

function seria() { return Array.from(arguments).toString();
}

или так:

const seria = (...a) => a.toString();

В задаче про кратчайший мемоизатор на одном из этапом использовалось это решение. Только помните, что строка '10' и число 10 будут сериализованы одинаково.

Вместо этого происходит конкатенация с separator. Нативный джойн элементов массива работает через арифметический цикл от 0 до length и не фильтрует отсутствующие элементы (null и undefined). Это приводит к следующему:

const ar = new Array(1000);
ar.toString()
< ",,,...,,," // 1000 times

Иначе могут быть последствия: Invalid string length, out of memory или просто повисший скрипт. Поэтому, если вы по той или иной причине добавляете в массив элемент с большим индексом (например, это сгенерированный натуральный id), ни в коем случае не джойните и, соответственно, не приводите к строке без предварительной подготовки. Используйте функции объекта 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 объект.

Только еще серьезнее, так как пустые и неподдерживаемые элементы представлены уже как "null": К слову, такая же опасность есть и при сериализации через JSON.stringify.

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)

Для типа number осуществляется следующее: Первое, что делает parseInt — это неявное приведение аргумента к строке через вызов абстрактной функции ToString, которая в зависимости от типа аргумента выполняет нужную ветку приведения.

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

А это значит, что в нашем случае parseInt работает не с "100... Я не буду дублировать здесь алгоритм определения предпочтительной формы, только отмечу следующее: если количество цифр числа в десятичной записи превышает 21, то будет выбрана экспоненциальная форма. Поэтому ответ совсем не ожидаемый 2^30. 000" а с "1e30". Кто знает природу этого магического числа 21 — пишите!

Встретив 'e', отсекает весь хвост, оставляя только "1". Далее parseInt смотрит на используемое основание системы счисления radix (по умолчанию 10, у нас — 2) и проверяет символы полученной строки на совместимость с ним. Результатом же будет целое число, полученное путем перевода из системы с основанием radix в десятичную — в нашем случае, это 1.

Обратная процедура:

(2**30).toString(2)

Он тоже имеет опциональный параметр radix. Здесь происходит вызов функции toString от объекта-прототипа Number, который использует тот же алгоритм приведения number к строке. Только он бросает RangeError для невалидного значения (должно быть целое от 2 до 36 включительно), тогда как parseInt возвращает NaN.

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

Задачка, чтобы отвлечься на минутку:

'3113'.split('').map(parseInt)

Что вернет и как исправить?

Обделенное вниманием

Отчасти, потому что лично мне не приходилось попадать с ними в передряги, да и интересного в них не много. Мы рассмотрели toString далеко не всех даже нативных объектов-прототипов. Если я все таки что-то зря обделил вниманием, упустил из виду или недопонял — обязательно пишите! Также мы не затронули функцию toLocaleString, так как про нее хорошо бы поговорить отдельно.

Призыв к бездействию

Кроме того я нахожу бессмысленным и немного бестолковым обсуждать подобное на технических собеседованиях: для этого есть вечные темы про замыкания, хойстинг, event loop, паттерны модуль/фасад/медиатор и "конечно" вопросы про [используемый фреймворк]. Приведенные мною примеры ни в коем случае не являются готовыми рецептами — только пищей для размышлений.

PS Язык JavaScript — удивителен! Настоящая статья получилась сборной солянкой, и я надеюсь вы нашли что-то интересное для себя.

Бонус

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

bonus

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


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

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] Создатели ботнета Mirai теперь сражаются с преступностью на стороне ФБР

Три подзащитных студента, стоявшие за ботнетом Mirai – онлайн-инструментом, учинившим разрушения по всему интернету осенью 2016 при помощи мощнейших распределённых атак на отказ от обслуживания – в четверг предстанут перед судом на Аляске и попросят судью вынести новый приговор: они ...

[Из песочницы] RESS — Новая архитектура для мобильных приложений

Вопреки провокационному заголовку, это никакая не новая архитектура, а попытка перевода простых и проверенных временем практик на новояз, на котором говорит современное Android-комьюнити Введение В последнее время стало больно смотреть на то, что творится в мире разработки под мобильные платформы. ...