Марейн Хавербеке - Выразительный JavaScript
// ... это наше личное свойство
}
}
Объекты без прототипов
Но кроличья нора на этом не заканчивается. А если кто-то зарегистрировал имя hasOwnProperty в объекте map и назначил ему значение 42? Теперь вызов map.hasOwnProperty обращается к локальному свойству, в котором содержится номер, а не функция.
В таком случае прототипы только мешаются, и нам бы хотелось иметь объекты вообще без прототипов. Мы видели функцию Object.create, что позволяет создавать объект с заданным прототипом. Мы можем передать null для прототипа, чтобы создать свеженький объект без прототипа. Это то, что нам нужно для объектов типа map, где могут быть любые свойства.
var map = Object.create(null);
map["пицца"] = 0.069;
console.log("toString" in map);
// → false
console.log("пицца" in map);
// → true
Так-то лучше! Нам уже не нужна приблуда hasOwnProperty, потому что все свойства объекта заданы лично нами. Мы спокойно используем циклы for/in без оглядки на то, что люди творили с Object.prototype.
Полиморфизм
Когда вы вызываете функцию String, преобразующую значение в строку, для объекта, он вызовет метод toString, чтобы создать осмысленную строчку. Я упомянул, что некоторые стандартные прототипы объявляют свои версии toString для создания строк, более полезных, чем просто "[object Object]".
Это простой пример мощной идеи. Когда кусок кода написан так, чтобы работать с объектами через определённый интерфейс – в нашем случае через метод toString – любой объект, поддерживающий этот интерфейс, можно подключить к коду, и всё будет просто работать.
Такая техника называется полиморфизм, хотя никто и не меняет своей формы. Полиморфный код может работать со значениями самых разных форм, пока они поддерживают одинаковый интерфейс.
Форматируем таблицу
Давайте рассмотрим пример, чтобы понять, как выглядит полиморфизм, да и вообще объектно-ориентированное программирование. Проект следующий: мы напишем программу, которая получает массив массивов из ячеек таблицы, и строит строку, содержащую красиво отформатированную таблицу. То есть, колонки и ряды выровнены. Типа вот этого:
name height country
------------ ------ -------------
Kilimanjaro 5895 Tanzania
Everest 8848 Nepal
Mount Fuji 3776 Japan
Mont Blanc 4808 Italy/France
Vaalserberg 323 Netherlands
Denali 6168 United States
Popocatepetl 5465 Mexico
Работать она будет так: основная функция будет спрашивать каждую ячейку, какой она ширины и высоты, и потом использует эту информацию для определения ширины колонок и высоты рядов. Затем она попросит ячейки нарисовать себя, и соберёт результаты в одну строку.
Программа будет общаться с объектами ячеек через хорошо определённый интерфейс. Типы ячеек не будут заданы жёстко. Мы сможем добавлять новые стили ячеек – к примеру, подчёркнутые ячейки у заголовка. И если они будут поддерживать наш интерфейс, они просто заработают, без изменений в программе. Интерфейс:
• minHeight() возвращает число, показывающее минимальную высоту, которую требует ячейка (выраженную в строчках)
• minWidth() возвращает число, показывающее минимальную ширину, которую требует ячейка (выраженную в символах)
• draw(width, height) возвращает массив длины height, содержащий наборы строк, каждая из которых шириной в width символов. Это содержимое ячейки.
Я буду использовать функции высшего порядка, поскольку они здесь очень уместны.
Первая часть программы вычисляет массивы минимальных ширин колонок и высот строк для матрицы ячеек. Переменная rows будет содержать массив массивов, где каждый внутренний массив – это строка ячеек.
function rowHeights(rows) {
return rows.map(function(row) {
return row.reduce(function(max, cell) {
return Math.max(max, cell.minHeight());
}, 0);
});
}
function colWidths(rows) {
return rows[0].map(function(_, i) {
return rows.reduce(function(max, row) {
return Math.max(max, row[i].minWidth());
}, 0);
});
}
Используя переменную, у которой имя начинается с (или полностью состоит из) подчёркивания (_), мы показываем тому, кто будет читать код, что этот аргумент не будет использоваться.
Функция rowHeights не должна вызвать затруднений. Она использует reduce для подсчёта максимальной высоты массива ячеек, и заворачивает это в map, чтобы пройти все строки в массиве rows.
Ситуация с colWidths посложнее, потому что внешний массив – это массив строк, а не столбцов. Я забыл упомянуть, что map (как и forEach, filter и похожие методы массивов) передаёт в заданную функцию второй аргумент – индекс текущего элемента. Проходя при помощи map элементы первой строки и используя только второй аргумент функции, colWidths строит массив с одним элементом для каждого индекса столбца. Вызов reduce проходит по внешнему массиву rows для каждого индекса, и выбирает ширину широчайшей ячейки в этом индексе.
Код для вывода таблицы:
function drawTable(rows) {
var heights = rowHeights(rows);
var widths = colWidths(rows);
function drawLine(blocks, lineNo) {
return blocks.map(function(block) {
return block[lineNo];
}).join(" ");
}
function drawRow(row, rowNum) {
var blocks = row.map(function(cell, colNum) {
return cell.draw(widths[colNum], heights[rowNum]);
});
return blocks[0].map(function(_, lineNo) {
return drawLine(blocks, lineNo);
}).join("n");
}
return rows.map(drawRow).join("n");
}
Функция drawTable использует внутреннюю функцию drawRow для рисования всех строк, соединяя их через символы новой строки.
Функция drawRow сперва превращает объекты ячеек строки в блоки, которые являются массивами строк, представляющими содержимое ячеек, разделённые линиями. Одна ячейка, содержащая число 3776, может быть представлена массивом из одного элемента ["3776"], а подчёркнутая ячейка может занять две строки и выглядеть как массив ["name", "----"].
Блоки для строки, у которых одинаковая высота, должны выводиться рядом друг с другом. Второй вызов map в drawRow строит этот результат построчно, начиная с ячеек самого левого блока, и для каждой из них дополняя строку до полной ширины таблицы. Полученные строки затем соединяются через символ новой строки, создавая целый ряд, который и возвращает drawRow.
Функция drawLine выцепляет строки, которые должны располагаться рядом друг с другом, из массива блоков и соединяет их через пробел, чтобы создать промежуток в один символ между столбцами таблицы.
Давайте напишем конструктор для ячеек, содержащих текст, который предоставляет интерфейс для ячеек. Он разбивает строчку в массив строк при помощи метода split, который режет строчку каждый раз, когда в ней встречается его аргумент, и возвращает массив полученных кусочков. Метод minWidth находит максимальную ширину строки в массиве.
function repeat(string, times) {
var result = "";
for (var i = 0; i < times; i++)
result += string;
return result;
}
function TextCell(text) {
this.text = text.split("n");
}
TextCell.prototype.minWidth = function() {
return this.text.reduce(function(width, line) {
return Math.max(width, line.length);
}, 0);
};
TextCell.prototype.minHeight = function() {
return this.text.length;
};
TextCell.prototype.draw = function(width, height) {
var result = [];
for (var i = 0; i < height; i++) {
var line = this.text[i] || "";
result.push(line + repeat(" ", width - line.length));
}
return result;
};
В коде используется вспомогательная функция repeat, которая возвращает переданную первым аргументом строку, повторённую переданное вторым аргументом количество раз. Метод draw использует её для создания отступов в ячейках, чтобы они все были необходимой длины.
Давайте нарисуем для опыта шахматную доску 5×5.
var rows = [];
for (var i = 0; i < 5; i++) {
var row = [];
for (var j = 0; j < 5; j++) {
if ((j + i) % 2 == 0)
row.push(new TextCell("##"));
else
row.push(new TextCell(" "));
}
rows.push(row);
}
console.log(drawTable(rows));
// → ## ## ##
// ## ##
// ## ## ##