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

в 6:27, , рубрики: ecma-262, javascript, toString, ненормальное программирование

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(/[objects(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(/functions+(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, поэтому и делюсь.

Автор: cerberus_ab

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js