Расширение встроенных объектов JavaScript при помощи прототипов - Прототипы
ОГЛАВЛЕНИЕ
Прототипы
Численные объекты наиболее интересны для рассмотрения. Со строками можно сделать множество вещей, но, как правило, большинство операций над строками передаются конкатенации или извлечению подстроки, которые уже предусмотрены для объекта String внутренне. Однако есть некоторые вещи, которые могут вам пригодиться.
Обрезание строкового литерала – одна из самых типичных проблем в обработке строк, особенно при разборе введенных пользователем данных; нет встроенного метода объекта String, который может превратить ' привет мир. ' в 'привет мир.'. Нельзя просто заменить все пробелы, так как при этом получится 'приветмир'. Есть только один способ сделать это:
// обрезание при помощи операторов массива
String.prototype.trim = function() { return this.split(/\s/).join(' '); }
Это удаляет пробелы в начале, в конце и в середине, и в итоговой строке (возвращенной) слова разделены одним и только одним пробелом. Задача выполнена путем разделения встроенной строковой функции, превращающей строку в массив, использующей любое вхождение пробела как знак-разделитель для разделения. Объединение массива обратно в строковый объект, с использованием (литерала) пробела как знака соединения, убирает любые пробелы в начале и в конце, а также в середине строки, оставляя один пробел между словами. Кстати, \s – это регулярное выражение, включающее табуляции, переводы строки, вертикальные табуляции или пробелы в целом, поэтому оно будет работать для целых абзацев.
Для начала рассмотрим синтаксис создания прототипа: встроенные объекты расширяются при помощи свойства prototype, с уникальным строковым литералом (желательно осмысленное имя), которому присваивается безымянная функция; функция может иметь ноль или любое количество аргументов, и тело функции следует объявлению прототипа. За исключением очень редких случаев с областями видимости вложенных объектов, ключевое слово this (этот) ссылается на экземпляр класса объекта, вызвавший функцию, т.е. на фактическую строку. Функция не обязательно должна быть безымянной, но присвоение ей имени может быть избыточным или вести к путанице:
// избыточное определение функции прототипа
String.prototype.trim =
function trim() { return this.split(/\s/).join(' '); };
...
// определение функции прототипа, вносящее путаницу
String.prototype.trim =
function trimblanks() { return this.split(/\s/).join(' '); };
Но присвоение функции имени бывает полезным, если вам понадобится вернуть метод объекта (или функцию) как строку; это можно сделать при помощи функции eval:
var s = eval(String.trim);
// s == 'function() { return this.split(/\s/).join(' '); }'
Если объявление прототипа присваивает функции имя, как показано ранее:
var s = eval(String.trim);
// s == 'function trim() { return this.split(/\s/).join(' '); }'
Синтаксис требует имя прототипа, но имя функции необязательно. Получится следующее, если расшифровать встроенную функцию:
var s = eval(String.substr);
// s == 'function substr() { [внутренний код] }'
Это всё, что касается разбора структуры механизма JavaScript.
Безусловно, есть другие способы реализации функции обрезания; программисты, прежде всего, знают, что обычно существует более одного решения конкретной проблемы. Может быть, математики прежде всего, а инженеры – непосредственно за ними. Рассмотрим разбиение строкового объекта при помощи метода замены строки и более замысловатого регулярного выражения:
// обрезание с заменой
String.prototype.trim2 =
function() { return this.replace(/^\s*(\S*(\s+\S+)*)\s*$/, '$1'); }
Это некрасиво, особенно если вы не знакомы с синтаксисом регулярных выражений. Мы заменяем любые начальные пробелы (\s, где * означает ноль или больше универсальных символов), пробелы, стоящие после начала строки (^), и любые пробелы в конце, стоящие перед концом строки ($). Между круглыми скобками регулярного выражения находится все, что начинается не с пробельного символа (\S*), но что может иметь пробелы посередине (вторые круглые скобки). Результат ($1) – в точности содержимое строки, которое вычисляется во внешних круглых скобках, и замена возвращается прототипом функции. Пробел между словами сохраняется в этом методе.
trim2 на самом деле был первым способом решения проблемы обрезания; он не делает преобразование типов и является всего лишь оберткой для встроенного метода. Но когда он был испытан на строке из 100.000 символов (большой файл с неформатированным текстом), метод replace (замена) оказался в среднем в 160 раз медленнее, чем операция разбиения-объединения! Для больших строк replace просто блокируется. trim2 все же может быть полезен, если вы хотите сохранить пробел между словами, но вам придется проверить длину и добавить в код логику, чтобы разбить строку символов на подходящие по длине куски для замены, делая код еще более уродливым, чем сейчас. В любом случае, редко бывает нужно сохранять пробел между словами, так как отображения выровненного по ширине текста можно легко достичь при помощи атрибута HTML, поэтому trim выигрывает как предпочтительный метод. В конце концов, trim – намного более изящный и удобоваримый, чем trim2.
Как только мы выполнили обрезание, получаем похожий на Visual Basic повторитель строк:
// похожий на Visual Basic повторитель строк
String.prototype.times = function(n)
{
var s = '';
for (var i = 0; i < n; i++)
s += this;
return s;
}
...
// используем прототип
var r = 'hey', q;
r = r.times(5); // r == 'heyheyheyheyhey'
q = '0'.times(4) // q == '0000'
Операция довольно очевидна, но с какой целью кто-то стал бы копировать строку n раз? Для этого есть немного применений, но VB, VBA и VBScript поддерживают функцию String(), а JavaScript - нет. В остальном, было найдено хорошее применение для этого, при форматировании чисел для дополнения нулями; но перед переходом к этому следует выделить несколько подсказок.
- Функция создает новое возвращаемое значение путем соединения n копий строки; рекомендуется оставлять входные аргументы нетронутыми (например, никогда не присваивать им); всегда можно использовать переназначение, как в примере;
- Функция требует две переменные, s, возвращаемое значение, и i, счётчик цикла; код инициализирует s пустой строкой (код не будет выполняться, если не сделать этого);
- JavaScript имеет свободную типизацию, поэтому ключевое слово var не обязательно;
- Тело функции может быть настолько сложным, насколько того требует операция, с любым числом локальных переменных и с полным доступом к механизму и к другим встроенным объектам;
- Функция производит действия над обеими переменными типа и над константами (литералами) типа, как показано в последней строке; это исключительная особенность JavaScript, по сравнению с большинством языков сценариев и языков программирования.
Функцию можно было назвать 'строка', аналогично VB, но это могло бы вызвать путаницу, наличие метода string в объекте String; '@ 5 раз' понятно без пояснений при чтении кем-то другим. Получаем функцию дополнения нулями:
// дополнение нулями
String.prototype.zp =
function(n) { return '0'.times(n - this.length) + this; }
...
// использование zp
var a = '5'.zp(5); // a == '00005'
Возможно, вы можете придумать лучшее имя для функции, но меня устраивает zp. Веселье начинается, когда мы расширяем прототип для объекта Number:
// строковые функции, которые мы хотим применять непосредственно к числам...
Number.prototype.zp = function(n) { return this.toString().zp(n); }
...
// ... таким образом, эту функцию можно использовать для чисел!
var b = (5 * 3).zp(5); // b == '00015'
var c = (b * 10).zp(2); // c == '150'
Была объявлена функция прототипа Number, обертывающая функцию прототипа String на основе другой функции прототипа String; также число превращается в строку с целью форматирования, чтобы здесь не было ограничений на возвращаемое значение, не было проверки типа аргумента (и не было принудительного подсчета аргументов) и, по сути, не было ограничений того, что можно сделать! Кстати, хотя b в примере является строкой, умножение ее на 10 превращает ее в число, и так как длина строки 150 больше, чем 2, функция String times возвращает пустую строку, идеально подходящую для данных целей.
Как насчет функции добавления нулей в конце? Это так же легко, как и инвертирование аргументов конкатенации:
// добавление нулей в конце
String.prototype.zt =
function(n) { return this + '0'.times(n - this.length); }
...
// будьте внимательны с результатами!
var b = (5 * 3).zt(5); // b == '15000'
Безусловно, в этом не заключается смысл добавления нулей с конца; можно было просто умножить на тысячу, чтобы получить такой же результат. Присоединение нулей с конца наиболее полезно при форматировании вещественных чисел (с плавающей точкой). Семейство функций C printf и, возможно, Microsoft Excel лучше всего справляются с этим, но еще не приходилось видеть собственную реализацию такой функции в языке сценариев. Кстати, функция VB Format даже не близка к printf, но, честно говоря, printf – это сложный кусок кода, обрабатывающий переменное число аргументов при помощи единственной форматирующей строки.
По запросу 'JavaScript printf' в гугле можно найти огромное количество реализаций; я тоже делал свою собственную, повторявшую машину состояний символов, обнаруженную в функции C printf; я просто перевел ее на JavaScript.
Форматный табличный вывод полезен, только если фактический вывод – это файл с неформатированным текстом. Это было замечательно, когда дисплеи были символьно-ориентированными, но мы больше не в Канзасе. Форматный вывод все же может иногда использоваться, если вам нужно тщательно разбирать текстовый файл для отладки или анализа; это обычно имеет место при корректировке файлов Direct3D .x, или других 3D файлов с неформатированным текстом. Но в целом, если форматный вывод – это табличные числовые данные, лучше не использовать это для файлов со значениями, разделёнными запятыми (.csv) или для файлов с разделителями табуляции: Excel импортирует и отформатирует их. В частности, вывод JavaScript, скорее всего, будет отображаться на какой-то веб-странице, поэтому можно использовать HTML для выполнения форматирования, по крайней мере, для решения вопроса выделения частей текста различными отступами.
Мы не будем использовать полноценную реализацию printf на JavaScript; если в ней нет реальной необходимости, следующий код будет изящно выполнять форматирование чисел с плавающей точкой. Начнем с усечения до конкретно указанного количества десятичных разрядов:
// усечение десятичных разрядов
Number.prototype.truncate = function(n)
{
return Math.round(this * Math.pow(10, n)) / Math.pow(10, n);
}
...
var a = 78.53981633974483;
var b = a.truncate(4); // b = 78.5398
var c = (5 / 2).truncate(4); // c = 2.5
var d = (199).truncate(4); // d = 199
Это простой сдвиг десятичных разрядов, взад и вперед, с встроенным усечением при помощи округления Math. Он работает хорошо, за исключением того, что для наших целей форматирования отсутствуют конечные нули для 2.5 или 199 в c и d выше.
Есть пара методов Global для разбора объектов в целочисленные и вещественные, но нет метода для извлечения дробной части действительного числа; это выглядит как хороший кандидат для прототипа Number:
// дробная часть числа
Number.prototype.fractional =
function() { return parseFloat(this) - parseInt(this); }
...
var f = a.fractional(); // f == 0.53981633974483
Теперь можно создать прототип функции форматирования числа:
// форматирование числа с n десятичными разрядами
Number.prototype.format = function(n)
{
// округление дробной части до n разрядов, пропуск '0.' и нулей в конце
var f = this.fractional().truncate(n).toString().substr(2).zt(n);
// целая часть + точка + дробная часть, пропуск '0.'
return parseInt(this) + '.' + f;
}
Условия, заставляющие это работать, заключаются в том, что оператор '+' operator выполняет правильное преобразование типов, поэтому можно 'складывать' (соединять) целое число и строку. Функция substr требует объект String, поэтому необходимо преобразование, но ничто не мешает определить substr как прототип Number и вызвать версию String.
// substr для чисел!
Number.prototype.substr =
function(n) { return this.toString().substr(n); }
Можно сказать, что это излишне(е), тем не менее, это может избавить от набора на клавиатуре .toString()там и сям!
Как сказано ранее, теги HTML могут справиться с выделением частей текста различными отступами; просто выровняйте ячейки таблицы по правому краю, чтобы сделать табличный вывод более удобочитаемым. Есть другие элементы форматирования, которых нет в нашем прототипе. Рассмотрите формат представления валюты, с тысячей разделительных знаков, как в функциях VBScript FormatNumber и FormatCurrency.
В данном случае нужно вставить разделитель группы цифр в целую часть, через каждые три цифры. Это строковая операция, а не числовая операция. Будет использоваться запятая (,) как разделитель тысяч, так как JavaScript всегда ожидает точку (.) в качестве десятичного разделителя. Есть способы обойти это для других языковых настроек, отличных от американского английского; в большинстве реализацией смешаны JavaScript и VBScript, но VBScript работает только в браузере IE. Так или иначе, замена разделителей должна выполняться непосредственно перед отображением вывода, а не между арифметическими операциями.
Десятичный разделитель обязателен для числа, но разделитель тысяч всего лишь улучшает читабельность числа, т.е., он просто декоративный. Нужно подчеркнуть важность форматирования после выполнения операций над числами. В конечном счёте, может потребоваться строковый прототип, способный избавляться от форматирования числа (то есть разделителей тысяч), чтобы получить фактический объект Number, который можно использовать в вычислениях.
Относительно того, почему остальной мир использует запятую как десятичный разделитель, международная организация стандартов утверждает, что труднее неправильно написать запятую от руки, и что точка может быть пятном на фотокопии, искажением при передаче факса или (можете ли вы поверить в это?) мухой на баннере! С общим обучением покончено, переходим к написанию кода.
Начнем с функции разделителя тысяч, отбрасывающей дробную часть. Простейший способ вставить разделители – просмотреть строковое представление числа справа налево, т.е., в обратном направлении. Как ни странно, в JavaScript нет встроенного метода инвертирования строки, поэтому приходится создавать собственный прототип:
// инвертирование строки
String.prototype.reverse =
function() { return this.split('').reverse().join(''); }
Он довольно простой: делим строку на части при помощи разделителя нулевой длины, который возвращает объект массива с одним символом в расчете на элемент. Объект Array имеет встроенный метод reverse, поэтому мы снова склеиваем его вывод в обратном направлении с разделителем нулевой длины, превращая 'Hello' в 'olleH'.
Как только мы получим перевернутую строку, остается вставить разделители тысяч:
// разделители тысяч целого числа
Number.prototype.group = function()
{
var s = parseInt(this).toString().reverse(), r = '';
for (var i = 0; i < s.length; i++)
r += (i > 0 && i % 3 == 0 ? ',' : '') + s.charAt(i);
return r.reverse();
}
Мы начинаем со строкового представления целой части и переворачиваем его; затем мы просматриваем его и собираем цифры в возвращаемой переменной r одновременно, и вставляем дополнительный префикс ',' через каждые три цифры. Проверьте документацию оператора остатка целочисленного деления % (и ваши знания математики), если вы не понимаете, что происходит. Повторное переворачивание результата завершает работу.
Старую функцию форматирования можно превратить в новую и улучшенную функцию format2:
// форматируем число с n десятичными разрядами и разделителем тысяч
Number.prototype.format2 = function(n)
{
// усекаем и добавляет нули в конец дробной части
var f = this.fractional().truncate(n).substr(2).zt(n);
// сгруппированная целая часть + точка + дробная часть
return this.group() + '.' + f;
}
Начиная с этого места, вы можете действовать согласно своей изобретательности; вы можете использовать второй аргумент для включения или выключения группировки, вы можете расширить функцию группировки, чтобы она заменяла действительные числа, вы можете сделать дробную часть необязательной, и т.д. Здесь дана хорошая основа (на) для начала разработки ваших собственных программ форматирования чисел. Можно увидеть варианты от программ форматирования при помощи рекурсивных регулярных выражений до обработчиков знаков валюты, не говоря о преобразователях чисел в текст, как те, которые используются для автоматической проверки правописания. Помните, что эти куски кода – всего лишь один способ сделать это!
Как сказано ранее, здесь есть функция, удаляющая форматированный ввод и возвращающая фактическое число с плавающей точкой. Разумеется, это метод String; форматированный ввод, скорее всего, будет строкой:
// очистка формата от строкового представления числа
String.prototype.clean =
function() { return parseFloat(this.replace(/,/g, '')); }
...
var a = 7853981.633974483;
var b = a.format(4); // b == '7,853,981.6340'
var c = b.clean(); // c == 7853981.634
Возвращенное число не имеет конечных нулей, бессмысленных для него в числовом выражении. Пример может казаться несколько неубедительным; можно было использовать в любом вычислении, но учтите, что ввод (b) поступает непосредственно из форматированного текстового файла, или из поля ввода: если его не очистить, parseFloat(b) вернет 7, а не 7 миллионов с чем-то; попытайтесь обвинить инвестора в такой ошибке! Мы ищем только запятые, и ничего больше; правильное регулярное выражение должно заменять нецифровые символы, десятичный разделитель (точку) и знак минуса:
String.prototype.clean =
function() { return parseFloat(this.replace(/[^0-9|.|-]/g, '')); }
Кстати, оно также избавляется от знаков валюты, если они есть. Входная строка со смешанным текстом и числами может давать неожиданные результаты; иными словами, функция ожидает чего-то, похожего на форматированное число, а не строку текста или абзац.
В функции есть еще один серьезный недостаток – она не способна правильно обрабатывать отрицательные числа. Код должен запоминать входной знак, и это еще один хороший кандидат в прототипы:
// бит знака числа (логический)
Number.prototype.sign = function() { return this < 0; }
Функция четко отвечает на вопрос 'является ли n отрицательным числом?', поэтому она работает аналогично знаковому разряду. Но дело не в этом; дело в том, что её можно использовать в качестве индекса для строки, чтобы способствовать решению проблемы:
// форматируем число с n десятичными разрядами разделителем тысяч и знаком
Number.prototype.format3 = function(n)
{
// запоминаем входной знак и удаляем его
var a = Math.abs(this);
// усекаем и убираем нули в конце дробной части
var f = a.fractional().truncate(n).substr(2).zt(n);
// знак + сгруппированная целая часть + точка + дробная часть
return '+-'.substr(this.sign(), 1) + a.group() + '.' + f;
}
Знаковый разряд выбирает + или -, и остальная часть такая же, после того как был убран знак. Если не нужен знак +, его можно заменить на пробел и, при желании, обрезать возвращенное значение.
Функции VBScript FormatCurrency и FormatNumber могут использовать круглые скобки для отрицательных чисел, но они не могут принудительно задавать знак '+'. И опять, круглые скобки – это только украшение, и особенно устаревшее в этом отношении, так как цвета лучше подходят для разграничения положительных и отрицательных чисел.
Это всё, что касается форматирования чисел. Эти прототипы полностью удовлетворяют потребности отображения, и на их основе можно создать превосходный код JavaScript printf. Они могут не быть впечатляющими, но они выполняют нужную работу: они задействуют очень простые манипуляции на очень простом, но, все же, очень мощном языке; они расширяют функциональность встроенных объектов, позволяя создать собственный диалект JavaScript, что само по себе больше, чем можно сказать о любом из альтернативных языков сценариев.
Прототипы объекта даты
Давайте обратим внимание на объект Date. Он имеет встроенные методы для каждой части даты, аналогично функции VBScript DatePart, но в нем нет аналогов DateDiff и DateAdd, поэтому они приведены ниже:
// разность дат в днях
Date.prototype.dateDiff = function(d)
{ return Math.round((d.valueOf() - this.valueOf()) / 86400000); }
// сложение дат
Date.prototype.add = function(n)
{
var d = new Date(this);
d.setDate(d.getDate() + n);
return d;
}
Date.prototype.addMonth = function(n)
{
var d = new Date(this);
d.setMonth(d.getMonth() + n);
return d;
}
Date.prototype.addYear = function(n)
{
var d = new Date(this);
d.setFullYear(d.getFullYear() + n);
return d;
}
...
var today = new Date();
var tomorrow = today.add(1);
var d = today.dateDiff(tomorrow); // d == 1
dateDiff использует встроенный метод valueOf, чтобы превращать даты в миллисекунды, прошедшие с общего фиксированного базового момента, и делить их на число миллисекунд в одном дне, затем округляет их в большую сторону до ближайшего целого - для получения результата в днях. Положительные результаты указывают, что аргумент находится впереди во времени; это немного улучшает синтаксис вызова; календарные операции, такие как 'сколько дней осталось до Пасхи?' могут стать весьма неприятными, если вычитать аргументы в неправильном порядке.
Сложение Date создает новую возвращаемую дату и меняет ее при помощи встроенных методов «установить» объекта Date. В Visual Basic можно складывать даты и числа, и числа интерпретируются как дни. В JavaScript складываются миллисекунды, поэтому прибавление целых дней, месяцев или лет требует прибавления эквивалентного числа миллисекунд в каждом конкретном случае. Но это не отменяет того, что разные месяцы имеют разное число дней, поэтому лучше бы было сдвигать часть даты на необходимое количество дней.
Предыдущие прототипы Date помогли создать пару других, которые находят первое и последнее число месяца:
// первое и последнее число месяца
Date.prototype.getFirstDate =
function() { var d = new Date(this);
d.setDate(1); return d; }
Date.prototype.getLastDate =
function() { var d = this.addMonth(1);
d.setDate(1); return d.add(-1); }
Они являются бесценными, особенно для создания или программирования веб-календарей. Получить первое число довольно легко, просто установите индексную часть дня в 1. Последнее число более сложное, выполняется переход на первое число следующего месяца и отнимается день. Программа проверки выхода за пределы диапазона тоже может оказаться полезной:
// дата между [d1, d2]
Date.prototype.between =
function(d1, d2) { return 0 <= d1.dateDiff(this) &&
d2.dateDiff(this) <= 0; }
...
var b = today.between(today.getFirstDate(),
today.getLastDate()); // b == true
Здесь нет проверки аргументов, поэтому точно передавайте даты, и убедитесь, что d1 < d2.
В том же режиме программирования календаря я однажды столкнулся с выделителем праздников. Воскресенья и праздники с фиксированной датой оказались весьма легкими, но переходящие, основанные на Пасхе праздники, были не столь простыми. Пасха сама является переходящей датой, поэтому необходима была функция вычисления Пасхи (грегорианский календарь):
// функция вычисления Пасхи
Date.prototype.getEaster = function(y)
{
if (!y)
y = this.getFullYear();
var c, n, k, i, j, l, m, d;
c = parseInt(y / 100);
n = y - 19 * parseInt(y / 19);
k = parseInt((c - 17) / 25);
i = c - parseInt(c / 4) - parseInt((c - k) / 3) + 19 * n + 15;
i = i - 30 * parseInt(i / 30);
i = i - parseInt(i / 28) *
(1 - parseInt(i / 28) * parseInt(29 / (i + 1)) *
parseInt((21 - n) / 11));
j = y + parseInt(y / 4) + i + 2 - c + parseInt(c / 4);
j = j - 7 * parseInt(j / 7);
l = i - j;
m = 3 + parseInt((l + 40) / 44);
d = l + 28 - 31 * parseInt(m / 4);
return new Date(y, m - 1, d);
}
Ввод – это полный год из четырех цифр, или год экземпляра даты, если аргумент не был передан. Если хотите, можете проверить математику, ее объяснил Дж. М. Оудин (1940); она была найдена на сайте ВМС США. Правило заключается в том, что Пасха – это первое воскресенье после первого церковного полнолуния, происходящего 21 марта или позже. Звучит несколько устаревшим, но такова традиция.
Как только была получена дата Пасхи, переходящие праздники связываются с ней, поэтому нужно лишь проверить совпадения дат:
// проверка местных праздников (венесуэльских)
Date.prototype.isHoliday = function()
{
// воскресенья
if (this.getDay() == 0)
return true;
var y = this.getFullYear();
var m = this.getMonth();
var d = this.getDate();
var r;
// фиксированные праздники
switch(m)
{
case 0: case 4: r = d == 1; break;
case 3: r = d == 19; break;
case 5: r = d == 24; break;
case 6: r = d == 24 || d == 5; break;
case 9: r = d == 12; break;
case 11: r = d == 25 || d == 31; break;
default:
}
// переходящие праздники, основанные на Пасхе
if (!r)
{
// выбор целой разности дат
switch (this.dateDiff(this.getEaster(y)))
{
case 2: case 3: case 48: case 47: r = true;
default:
}
}
return r;
}
Легко можно менять местные праздники путем изменения месяцев и дат для фиксированных праздников, и сдвига значений для праздников, основанные на Пасхе. Пример касается венесуэльских праздников.