Почитывая рассылку v8-users, заметил интересную тему про оптимизации, специфичные для v8. Особенно интересным показалось сообщение о ConsStrings (cons в словаре). И вот, собственно, об этих «составных строках» мне и хочется рассказать. Так как восторг! :)
Веб текстовый как со стороны клиента, так и со стороны сервера (особенно сервера). И всем нам постоянно приходится клеить кучу мелких строк в большие строки, а эти большие — в еще большие, и так далее. На сервере путь последней, самой большой строки (отрендеренной странички, например), оканчивается на передаче их ядру операционки для отправки по сети. В браузере, обычно, строки финишируют на присваивании node.innerHTML = string или так же, как на сервере — перед отправкой в сокет (аяксом, например).
Самая вкуснятина в том, что большинство промежуточных строк никому не нужно. Они часто даже своих переменных не имеют и являются всего лишь временными контейнерами для строк большего размера. В худшем случае на каждую конкатенацию требуется: сначала выделить память для новой строки, проинициализировать ее, защитить от сборки мусора, скопировать в выделенную память две (!) склеиваемые строки, передать полученную строку дальше, а потом уничтожить. Но всего этого можно избежать, если…
… если строки в вашем любимом языке нельзя изменять, то есть при любом воздействии всегда создается новая строка, а исходная строка остается нетронутой. Тогда пропадает необходимость сразу копировать конкатенируемые строки в новую область памяти, достаточно только запомнить, из каких строк состоит новая склеенная строка, а реальное копирование отложить до лучших времен. Может показаться, что все эти манипуляции только усложнят и замедлят работу со строками, но нет, все наоборот (особенно в скриптовом языке), и мы это проверим в небольшом тесте.
Судя по сообщению в рассылке, в v8 конкатенация строк построена как раз по такому принципу (в перле, помнится, это тоже уже давно есть). Цитирую это сообщение Эрика Кори: «Когда вы используете +=, V8 создает структуру типа ConsString, это узел дерева, который занимает 20 байт на 32-х разрядных машинах или 32 байта — на 64-х разрядных. Это относительно быстрая операция. Как только вы пытаетесь использовать строку для чего-то отличного от конкатенации, она превращается в простую «плоскую» строку похожим способом, как бы это сделал join (имеется ввиду Array#join()). Узел ConsString становится мусором, который мы можем собрать довольно быстро. Алгоритм, по которому должен работать join, описан в крайне загадочных спецификациях (например, работа с разреженными массивами), потому join — это не совсем простая операция…».
И последнее, что мне бы хотелось узнать (сейчас уже не осилю, но как узнаю — допишу), умеет ли кто-нибудь так же хитрить со сравнением строк. Ведь, при такой отложенной склейке строк, можно заодно складывать контрольные суммы этих строк. А когда понадобится сравнить две составные строки строки, то можно будет сначала сравнить их контрольные суммы, и только если суммы совпадают, собрать обе строки и честно сравнить.
Сравнить производительность простой конкатенации (от которой мы всегда старались уйти) и склеивания массива очень просто. Единственный важный момент, чтобы честно сравнить время, надо попросить движок яваскрипта «собрать» полученную строку (например, пробежавшись регуляркой), если она у него хранится как дерево. Вот небольшой тестик (полная версия):
// испечем массив исходных строк
// обязательно разных и обязательно честных,
// иначе будем мерить неизвестно что
function bake (arr, prefix, count)
{
console.time('bake')
var rex = /xxx/
for (var i = 0; i < count; i++)
arr[i] = prefix + i,
rex.test(arr[i]) // «собираем» строку
console.timeEnd('bake')
}
var count = 100000, // сколько строк
rex = /^\w/ // тестовый рекс
;(function(){
var src = [], prefix = 'testA'
bake(src, prefix, count)
// берем самый неприятный случай,
// когда мы не знаем сколько будет строк
var joiner = []
console.time('join()')
for (var i = 0; i < count; i++)
// а еще мы не можем использовать конструкцию joiner[i] = …
// так как в реальной задаче у нас может не быть цикла
joiner.push(src[i])
var res = joiner.join('')
var test = rex.test(res)
console.timeEnd('join()')
console.log(test, 'regexp test')
console.log(res === src.join(''), 'index of prefix')
})();
;(function(){
var src = [], prefix = 'testB'
bake(src, prefix, count)
var joiner = ''
console.time('+=')
for (var i = 0; i < count; i++)
joiner += src[i]
var res = joiner // дублируем, чтобы быть честными
var test = rex.test(res)
console.timeEnd('+=')
console.log(test, 'regexp test')
console.log(res === src.join(''), 'index of prefix')
})();
Тестю все на том же стареньком интеловском маке.
Результаты вполне ожидаемые. В Хроме простая конкатенация строк в два раза быстрее, чем склеивание массива строк. В Сафари строго наоборот: массив в два раза быстрее строк. Фаерфокс показывает противоречивые результаты: в 3.0 и 3.5 строки процентов на 15% быстрее, чем склейка массива, а в 3.6 наоборот — строки немного отстают. Пока сошлюсь на погрешность измерений. В Опере картинка поинтереснее, все оперы складывают строки до нескольких раз раз быстрее, чем склеивают массив (зачет!). В эксплорере тоже не все печально: шестой делает join() в 50 раз быстрее конкатенации, седьмой — так же и за то же время, а вот восьмой складывает строки примерно в два раза быстрее, чем склеивает массив, да еще делает любое из этих действий в 100 раз быстрее чем шестой или седьмой на том же железе.