Выразительный JavaScript - Марейн Хавербеке 14 стр.


// → … красиво отформатированная таблица

Наследование – основная часть объектно-ориентированной традиции, вместе с инкапсуляцией и полиморфизмом. Но, в то время как последние две воспринимают как отличные идеи, первая вызывает споры.

В основном потому, что её обычно путают с полиморфизмом, представляют более мощным инструментом, чем она на самом деле является, и используют не по назначению. Тогда как инкапсуляция и полиморфизм используются для разделения частей кода и уменьшения связанности программы, наследование связывает типы вместе и создаёт большую связанность.

Мы можем использовать полиморфизм без наследования. Я не советую вам полностью избегать наследования – я его использую регулярно в своих программах. Но относитесь к нему как к более хитрому трюку, который позволяет определять новые типы с минимумом кода – а не как к основному принципу организации кода. Предпочтительно расширять типы при помощи композиции – как UnderlinedCell построен на использовании другого объекта ячейки. Он просто хранит его в свойстве и перенаправляет вызовы из своих в его методы.

Оператор instanceof

Иногда удобно знать, произошёл ли объект от конкретного конструктора. Для этого JavaScript даёт нам бинарный оператор instanceof.

console.log(new RTextCell("A") instanceof RTextCell);

// → true

console.log(new RTextCell("A") instanceof TextCell);

// → true

console.log(new TextCell("A") instanceof RTextCell);

// → false

console.log([1] instanceof Array);

// → true

Оператор проходит и через наследованные типы. RTextCell является экземпляром TextCell, поскольку RTextCell.prototype происходит от TextCell.prototype. Оператор также можно применять к стандартным конструкторам типа Array. Практически все объекты – экземпляры Object.

Итог

Получается, что объекты чуть более сложны, чем я их подавал сначала. У них есть прототипы – это другие объекты, и они ведут себя так, как будто у них есть свойство, которого на самом деле нет, если это свойство есть у прототипа. Прототипом простых объектов является Object.prototype.

Конструкторы – функции, имена которых обычно начинаются с заглавной буквы – можно использовать с оператором new для создания объектов. Прототипом нового объекта будет объект, содержащийся в свойстве prototype конструктора. Это можно использовать, помещая в прототип свойства, общие для всех экземпляров данного типа. Оператор instanceof, если ему дать объект и конструктор, может сказать, является ли объект экземпляром этого конструктора.

Для объектов можно сделать интерфейс и сказать всем, чтобы они общались с объектом только через этот интерфейс. Остальные детали реализации объекта теперь инкапсулированы, скрыты за интерфейсом.

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

Определяя несколько типов, различающихся только в мелких деталях, бывает удобно просто наследовать прототип нового типа от прототипа старого типа, чтобы новый конструктор вызывал старый. Это даёт вам тип объекта, сходный со старым, но при этом к нему можно добавлять свойства или переопределять старые.

Упражнения

Векторный тип

Напишите конструктор Vector, представляющий вектор в двумерном пространстве. Он принимает параметры x и y (числа), которые хранятся в одноимённых свойствах.

Дайте прототипу Vector два метода, plus и minus, которые принимают другой вектор в качестве параметра и возвращают новый вектор, который хранит в x и y сумму или разность двух векторов (один this, второй – аргумент).

Добавьте геттер length в прототип, подсчитывающий длину вектора – расстояние от (0, 0) до (x, y).

// Ваш код

console.log(new Vector(1, 2).plus(new Vector(2, 3)));

// → Vector{x: 3, y: 5}

console.log(new Vector(1, 2).minus(new Vector(2, 3)));

// → Vector{x: -1, y: -1}

console.log(new Vector(3, 4).length);

// → 5

Ещё одна ячейка

Создайте тип ячейки StretchCell(inner, width, height), соответствующий интерфейсу ячеек таблицы из этой главы. Он должен оборачивать другую ячейку (как делает UnderlinedCell), и убеждаться, что результирующая ячейка имеет как минимум заданные ширину и высоту, даже если внутренняя ячейка – меньше.

// Ваш код.

var sc = new StretchCell(new TextCell("abc"), 1, 2);

console.log(sc.minWidth());

// → 3

console.log(sc.minHeight());

// → 2

console.log(sc.draw(3, 2));

// → ["abc", " "]

Интерфейс к последовательностям

Разработайте интерфейс, абстрагирующий проход по набору значений. Объект с таким интерфейсом представляет собой последовательность, а интерфейс должен давать возможность в коде проходить по последовательности, работать со значениями, которые её составляют, и как-то сигнализировать о том, что мы достигли конца последовательности.

Задав интерфейс, попробуйте сделать функцию logFive, которая принимает объект-последовательность и вызывает console.log для первых её пяти элементов – или для меньшего количества, если их меньше пяти.

Затем создайте тип объекта ArraySeq, оборачивающий массив, и позволяющий проход по массиву с использованием разработанного вами интерфейса. Создайте другой тип объекта, RangeSeq, который проходит по диапазону чисел (его конструктор должен принимать аргументы from и to).

// Ваш код.

logFive(new ArraySeq([1, 2]));

// → 1

// → 2

logFive(new RangeSeq(100, 1000));

// → 100

// → 101

// → 102

// → 103

// → 104

7. Проект: электронная жизнь

Вопрос о том, могут ли машины думать, так же уместен, как вопрос о том, могут ли подводные лодки плавать.

Эдсгер Дейкстра, "Угрозы вычислительной науке"

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

Наш проект – постройка виртуальной экосистемы, небольшого мира, населённого существами, которые двигаются и борются за выживание.

Определение

Чтобы задача стала выполнимой, мы кардинально упростим концепцию мира. А именно – мир будет двумерной сеткой, где каждая сущность занимает одну клетку. На каждом ходу существа получат возможность выполнить какое-либо действие.

Таким образом, мы порубим время и пространство на единицы фиксированного размера: клетки для пространства и ходы для времени. Конечно, это грубое и неаккуратное приближение. Но наша симуляция должна быть развлекательной, а не аккуратной, поэтому мы свободно "срезаем углы".

Определить мир мы можем при помощи плана – массива строк, который раскладывает мировую сетку, используя один символ на клетку.

var plan = ["############################",

"# # # o ##",

"# #",

"# ##### #",

"## # # ## #",

"### ## # #",

"# ### # #",

"# #### #",

"# ## o #",

"# o # o ### #",

"# # #",

"############################"];

Символ "#" обозначает стены и камни, "o" – существо. Пробелы – пустое пространство.

План можно использовать для создания объекта мира. Он следит за размером и содержимым мира. У него есть метод toString, который преобразовывает мир в выводимую строчку (такую, как план, на котором он основан), чтобы мы могли наблюдать за происходящим внутри него. У объекта мир есть метод turn (ход), позволяющий всем существам сделать один ход и обновляющий состояние мира в соответствии с их действиями.

Изображаем пространство

У сетки, моделирующей мир, заданы ширина и высота. Клетки определяются координатами x и y. Мы используем простой тип Vector (из упражнений к предыдущей главе) для представления этих пар координат.

function Vector(x, y) {

this.x = x;

this.y = y;

}

Vector.prototype.plus = function(other) {

return new Vector(this.x + other.x, this.y + other.y);

};

Потом нам нужен тип объекта, моделирующий саму сетку. Сетка – часть мира, но мы делаем из неё отдельный объект (который будет свойством мирового объекта), чтобы не усложнять мировой объект. Мир должен загружать себя вещами, относящимися к миру, а сетка – вещами, относящимися к сетке.

Для хранения сетки значений у нас есть несколько вариантов. Можно использовать массив из массивов-строк, и использовать двухступенчатый доступ к свойствам:

var grid = [["top left", "top middle", "top right"],

["bottom left", "bottom middle", "bottom right"]];

console.log(grid[1][2]);

// → bottom right

Или мы можем взять один массив, размера width × height, и решить, что элемент (x, y) находится в позиции x + (y × width).

var grid = ["top left", "top middle", "top right",

"bottom left", "bottom middle", "bottom right"];

console.log(grid[2 + (1 * 3)]);

// → bottom right

Поскольку доступ будет завёрнут в методах объекта сетки, внешнему коду всё равно, какой подход будет выбран. Я выбрал второй, потому что с ним проще создавать массив. При вызове конструктора Array с одним числом в качестве аргумента он создаёт новый пустой массив заданной длины.

Следующий код объявляет объект Grid (сетка) с основными методами:

function Grid(width, height) {

this.space = new Array(width * height);

this.width = width;

this.height = height;

}

Grid.prototype.isInside = function(vector) {

return vector.x >= 0 && vector.x < this.width &&

vector.y >= 0 && vector.y < this.height;

};

Grid.prototype.get = function(vector) {

return this.space[vector.x + this.width * vector.y];

};

Grid.prototype.set = function(vector, value) {

this.space[vector.x + this.width * vector.y] = value;

};

Элементарный тест:

var grid = new Grid(5, 5);

console.log(grid.get(new Vector(1, 1)));

// → undefined

grid.set(new Vector(1, 1), "X");

console.log(grid.get(new Vector(1, 1)));

// → X

Программный интерфейс существ

Перед тем, как заняться конструктором мира World, нам надо определиться с объектами существ, населяющих его. Я упомянул, что мир будет спрашивать существ, какие они хотят произвести действия. Работать это будет так: у каждого объекта существа есть метод act, который при вызове возвращает действие action. Action – объект типа property, который называет тип действия, которое хочет совершить существо, к примеру "move". Action может содержать дополнительную информацию – такую, как направление движения.

Существа ужасно близоруки и видят только непосредственно прилегающие к ним клетки. Но и это может пригодиться при выборе действий. При вызове метода act ему даётся объект view, который позволяет существу изучить прилегающую местность. Мы называем восемь соседних клеток их направлениями по компасу: "n" на север, "ne" на северо-восток, и т. п. Вот какой объект будет использоваться для преобразования из названий направлений в смещения по координатам:

var directions = {

"n": new Vector( 0, -1),

"ne": new Vector( 1, -1),

"e": new Vector( 1, 0),

"se": new Vector( 1, 1),

"s": new Vector( 0, 1),

"sw": new Vector(-1, 1),

"w": new Vector(-1, 0),

"nw": new Vector(-1, -1)

};

У объекта view есть метод look, который принимает направление и возвращает символ, к примеру "#", если там стена, или пробел, если там ничего нет. Объект также предоставляет удобные методы find и findAll. Оба принимают один из символов, представляющих вещи на карте, как аргумент. Первый возвращает направление, в котором этот предмет можно найти рядом с существом, или же null, если такого предмета рядом нет. Второй возвращает массив со всеми возможными направлениями, где найден такой предмет. Например, существо слева от стены (на западе) получит ["ne", "e", "se"] при вызове findAll с аргументом "#".

Вот простое тупое существо, которое просто идёт, пока не врезается в препятствие, а затем отскакивает в случайном направлении.

function randomElement(array) {

return array[Math.floor(Math.random() * array.length)];

}

function BouncingCritter() {

this.direction = randomElement(Object.keys(directions));

};

BouncingCritter.prototype.act = function(view) {

if (view.look(this.direction) != " ")

this.direction = view.find(" ") || "s";

return {type: "move", direction: this.direction};

};

Вспомогательная функция randomElement просто выбирает случайный элемент массива, используя Math.random и немного арифметики, чтобы получить случайный индекс. Мы и дальше будем использовать случайность, так как она – полезная штука в симуляциях.

Конструктор BouncingCritter вызывает Object.keys. Мы видели эту функцию в предыдущей главе – она возвращает массив со всеми именами свойств объекта. Тут она получает все имена направлений из объекта directions, заданного ранее.

Конструкция || "s" в методе act нужна, чтобы this.direction не получил null, в случае если существо забилось в угол без свободного пространства вокруг – например, окружено другими существами.

Мировой объект

Теперь можно приступать к мировому объекту World. Конструктор принимает план (массив строк, представляющих сетку мира) и объект legend. Это объект, сообщающий, что означает каждый из символов карты. В нём есть конструктор для каждого символа – кроме пробела, который ссылается на null (представляющий пустое пространство).

function elementFromChar(legend, ch) {

if (ch == " ")

return null;

var element = new legend[ch]();

element.originChar = ch;

return element;

}

function World(map, legend) {

var grid = new Grid(map[0].length, map.length);

this.grid = grid;

this.legend = legend;

map.forEach(function(line, y) {

for (var x = 0; x < line.length; x++)

grid.set(new Vector(x, y),

elementFromChar(legend, line[x]));

});

Назад Дальше