Глава 6
Тайният живот на обектите

Проблемът с обектно - ориентираните езици е, че те имат всичко, скрито в средата, която носят със себе си. Ти искаш банан, но това което получаваш е горила държаща банан и цялата джунгла”

Joe Armstrong, интервюиран в Coders при работа

Когато един програмист казва “обект”, това е препълнен термин. В моята професия, обектите са начин на живот, предмет на свещени войни, обичана модерна дума, която все още не е загубила своята сила.

За външен човек, това е може би малко объркващо. Нека да започнем с кратка история на обектите, като програмна конструкция.

История

Тази история, както повечето истории за програмиране, започва с проблема за сложността. Една философия казва, че сложността може да се направи лесно управляема, чрез разделянето и на малки отделения, които са изолирани едно от друго. Тези отделения, са били наречени objects.

Един обект е твърда черупка, която крие лепкава сложност в нея и вместо това ни предлага няколко копчета и съединители (като методи), които предоставят интерфейс, чрез който обекта може да се използва. Идеята е интерфейса да е сравнително прост, а всички сложни неща, които се случват в обекта да бъдат игнорирани, когато се работи с него.

A simple interface can hide a lot of complexity.

Като пример, можете да си представите един обект, който осигурява интерфейса на вашия екран. Той осигурява начини да се чертаят фигури и текст в пространството, но крие всички детайли, как тези форми се превръщат в реални пиксели на вашия екран. Ще имаме набори от методи, като например drawCircle и това е единственото нещо, което трябва да знаете за да използвате такъв обект.

Тези идеи бяха първоначално разработени през 1970г. и 1980г., но през 1990г е била извършена революция в обектно-ориентираното програмиране. Внезапно много хора започват да казват, че обектите са правилният начин за програмиране и всичко, което не включва обекти е остаряла глупост.

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

Аз предпочитам да разгледаме въпроса от практическия, а не от идеологическия ъгъл. Има няколко полезни концепции, като една от важните е капсулиране (разграничаване между вътрешна сложност и външен интерфейс), която популяризира обектно-ориентираната култура. И тя заслужава да се изучава.

Тази глава описва доста ексцентрични ползвания на обекти в JavaScript и начини, които се отнасят до класически обектно-ориентирани техники.

Методи

Методите са просто свойства, които притежават функционални стойности. Това е един прост метод:

var rabbit = {};
rabbit.speak = function(line) {
  console.log("The rabbit says '" + line + "'");
};

rabbit.speak("I'm alive.");
// → The rabbit says 'I'm alive.'

Обикновено метода трябва да прави нещо с обекта, върху който е извикан. Когато дадена функция е извикана, като метод - поглежда нагоре за свойство и незабавно го извиква, както object.method() - специалната променлива this в нейното тяло сочи към обекта, за който е извикана.

function speak(line) {
  console.log("The " + this.type + " rabbit says '" +
              line + "'");
}
var whiteRabbit = {type: "white", speak: speak};
var fatRabbit = {type: "fat", speak: speak};

whiteRabbit.speak("Oh my ears and whiskers, " +
                  "how late it's getting!");
// → The white rabbit says 'Oh my ears and whiskers, how
//   late it's getting!'
fatRabbit.speak("I could sure use a carrot right now.");
// → The fat rabbit says 'I could sure use a carrot
//   right now.'

Кодът използва ключовата дума this, за да изведе типа на заека, който говори. Спомнете си, че методите apply и bind използват първия аргумент за симулиране на извикване на метод. Този първи аргумент в действителност се използва, за да даде стойност на this.

Съществува метод, подобен на apply, наречен call. Той също извиква метод функция, но взема аргументите си нормално, а не като масив. Както apply и bind, call може да предава специфична this стойност.

speak.apply(fatRabbit, ["Burp!"]);
// → The fat rabbit says 'Burp!'
speak.call({type: "old"}, "Oh my.");
// → The old rabbit says 'Oh my.'

Прототипи

Гледайте внимателно:

var empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]

Аз просто превърнах свойство в празен обект. Магия!

Е, не съвсем. Просто промених източника на информация за начина, по който в JavaScript обектите работят. В допълнение към техния набор от свойства, почти всички обекти имат prototype (прототип). Прототипът е друг обект, който се използва, като източник на аварийни свойства. Когато обект получава искане за свойство, с което не разполага, това свойство ще се потърси в неговия прототип, а след това в прототипа на прототипа и т.н.

Така че, кой е прототипа на празния обект? Това е голяма поредица от прототипи, които стоят зад почти всички обекти Object.prototype.

console.log(Object.getPrototypeOf({}) ==
            Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

Както може да се очаква Object.getPrototypeOf функцията, връща прототипа на обекта.

Отношенията на прототипите на обектите в JavaScript, образуват дървовидна структура, а в основата на тази структура стои Object.prototype. Той осигурява няколко метода, които са във всички обекти, като toString, който конвентира един обект за представяне в string.

Много обекти нямат директно Object.prototype, като свой основен прототип, но вместо това имат друг обект, който предоставя своите собствени свойства по подразбиране. Функциите получават този обект от Function.prototype, а масивите от Array.prototype.

console.log(Object.getPrototypeOf(isNaN) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) ==
            Array.prototype);
// → true

Такъв обект прототип често ще има прототип Object.prototype и така все пак ще може косвено да осигурява методи, като toString.

Функцията Object.getPrototypeOf очевидно връща прототипа на даден обект. Може да използвате Object.create за да създадете обект с определен прототип.

var protoRabbit = {
  speak: function(line) {
    console.log("The " + this.type + " rabbit says '" +
                line + "'");
  }
};
var killerRabbit = Object.create(protoRabbit);
killerRabbit.type = "killer";
killerRabbit.speak("SKREEEE!");
// → The killer rabbit says 'SKREEEE!'

“protoRabbit” работи, като контейнер за свойства, които се споделят от всички зайци. Индивидуален заек обект, като “killerRabbit”, съдържа свойства, които се прилагат само за него (в този случай неговия тип) и получава споделени свойства от своя прототип.

Конструктори

По-удобен начин за създаване на обекти, които произлизат от някой споделен прототип е да се използва constructor (конструктор). В JavaScript извиквайки функция с ключовата дума new пред нея, определя тази функция, като конструктор. Конструкторът има променлива this обвързана с новия обект и ако освен изрично не и е казано да върне друг обект, този нов обект ще бъде върнат от извикването.

За един обект създаден с new се казва, че е инстанция на своя конструктор.

Ето един прост конструктор за зайци. Това е конвенция, на капитализация на имената на конструкторите, така че лесно да се различават от останалите функции.

function Rabbit(type) {
  this.type = type;
}

var killerRabbit = new Rabbit("killer");
var blackRabbit = new Rabbit("black");
console.log(blackRabbit.type);
// → black

Конструкторите (в действителност всички функции) автоматично получават свойството на име prototype, което по подразбиране притежава един обикновен празен обект, който произлиза от Object.prototype. Всеки модел създаден с конструктор ще има този обект, като негов прототип. Така че, за да добавите speak метод на зайците, създадени с Rabbit конструктора, можете да направите това:

Rabbit.prototype.speak = function(line) {
  console.log("The " + this.type + " rabbit says '" +
              line + "'");
};
blackRabbit.speak("Doom...");
// → The black rabbit says 'Doom...'

Важно е да се отбележи разликата между начина, по който прототипа се свързва с конструктора (чрез своето prototype свойство) и начина, по който обектите имат прототип (което може да бъде възстановен с Object.getPrototypeOf). Действителния прототип на конструктора е Function.prototype, както за конструктори на функции. Неговото свойство prototype ще бъде копие на прототипа създаден чрез него, но не и негов собствен прототип.

Първостепенно получени свойства

Когато добавите свойство към обект, независимо дали го има в прототипа или не, свойството се добавя към обекта, който от тук на татък ще го има, като своя собственост. Ако има свойство със същото име в прототипа, това свойство вече няма да се отрази на обекта. Самият прототип не се променя.

Rabbit.prototype.teeth = "small";
console.log(killerRabbit.teeth);
// → small
killerRabbit.teeth = "long, sharp, and bloody";
console.log(killerRabbit.teeth);
// → long, sharp, and bloody
console.log(blackRabbit.teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small

Диаграмата по-долу показва ситуацията след, като кода е свършил. Прототипите на Rabbit и Object стоят зад killerRabbit, като вид фон, където могат да се разгледат свойствата, които не се намират в самия обект.

Rabbit object prototype schema

Приоритетни свойства, които съществуват в прототипа е често полезно нещо, което може да се направи. Примера със заешките зъби показва, че може да се използва за изразяване на изключителни свойства в случаи на по-общ клас от обекти, като същевременно позволява на nonexceptional обекти просто да вземат стандартна стойност от техния прототип.

Това се използва, за да даде стандартна функция и масив с прототипи, на по-различен toString метод от основния обект прототип.

console.log(Array.prototype.toString ==
            Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2

Извикването на toString върху масива, дава резултат подобен на .join(","), който поставя запетая между стойностите на масива. Директното извикване на Object.prototype.toString с масив, произвежда друг string. Тази функция не разбира от масиви, така че просто поставя думата “object” и името на типа между квадратни скоби.

console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]

Prototype interference

Прототипът може да се използва по всяко време за да се добавят нови свойства и методи на всички обекти базирани на него. Например може да се окаже необходимо зайците да танцуват.

Rabbit.prototype.dance = function() {
  console.log("The " + this.type + " rabbit dances a jig.");
};
killerRabbit.dance();
// → The killer rabbit dances a jig.

Това е удобно. Но има ситуации, където причинява проблеми. В предните глави, ние използвахме един обект, като начин да се свържат стойности с имена, като създаваме свойства за имената и им даваме съответна стойност, като тяхна стойност. Ето един пример от Глава 4:

var map = {};
function storePhi(event, phi) {
  map[event] = phi;
}

storePhi("pizza", 0.069);
storePhi("touched tree", -0.081);

Можем да обходим всички phi стойности в обекта, като използваме for/in цикъл и тест за проверка дали дадено име е там с помощта на in оператора. Но за съжаление, прототипа на обекта застава на пътя ни.

Object.prototype.nonsense = "hi";
for (var name in map)
  console.log(name);
// → pizza
// → touched tree
// → nonsense
console.log("nonsense" in map);
// → true
console.log("toString" in map);
// → true

// Delete the problematic property again
delete Object.prototype.nonsense;

Това цялото е грешно. Няма събитие “nonsense” в нашия набор от данни. Там няма и събитие наречено “toString”.

Странното е, че toString не се появи в for/in цикъла, но оператора in се върна за него. Това е така защото JavaScript разграничава enumerable и nonenumerable свойствата.

Всички свойства, които създаваме просто им възлагаме enumerable. Стандартните свойства в Object.prototype са nonenumerable, поради което не се появяват в for/in цикла.

Възможно е да определим наши собствени nonenumerable свойства, чрез използване на функцията Object.defineProperty, която ни позволява да контролираме вида на свойствата, които създаваме.

Object.defineProperty(Object.prototype, "hiddenNonsense",
                      {enumerable: false, value: "hi"});
for (var name in map)
  console.log(name);
// → pizza
// → touched tree
console.log(map.hiddenNonsense);
// → hi

Така че, сега свойството е там, но то няма да се появи в цикъла. Това е добре. Но ние все още имаме проблем с оператора in, който твърди, че свойствата на Object.prototype съществуват в нашия обект. За това можем да използваме върху обекта, метода hasOwnProperty.

console.log(map.hasOwnProperty("toString"));
// → false

Този метод ни казва дали обекта има това свойство, без да поглежда в своите прототипи. Това често е по-полезна информация от това, което оператора in ни дава.

Когато се притеснявате, че някой (някакъв друг код, който се зарежда в програмата ви), може да бърника в базовя прототип на обекта, ви препоръчвам да напишете един for/in цикъл, като този:

for (var name in map) {
  if (map.hasOwnProperty(name)) {
    // ... this is an own property
  }
}

Prototype-less objects

Но заешката дупка не свършва дотук. Какво става, ако някой е регистрирал името hasOwnProperty в нашия map обект и го е настроил на стойност 42? Сега map.hasOwnProperty ще се опита да извика локалното свойство, което притежава номера, а не функцията.

В такъв случай прототипите просто стоят на пътя и ние действително бихме предпочели да има обекти без прототипи. Видяхме функцията Object.create, която ни позволява да създадем обект с определен прототип. Вие имате право да подадете null, като прототип, за да създадете нов обект без прототип. За обекти, като map, където свойствата могат да бъдат всичко, това е точно онова, което искаме.

var map = Object.create(null);
map["pizza"] = 0.069;
console.log("toString" in map);
// → false
console.log("pizza" in map);
// → true

Много по-добре! Вече не се нуждаем от hasOwnProperty, защото всички свойства на обекта са само собствените му свойства. Сега спокойно можем да използваме for/in цикъла, независимо от това, което хората са правили на Object.prototype.

Полиморфизъм

Когато извиквате String функцията, която превръща стойност в string в даден обект, тя ще извика метода toString върху този обект, за да се опита да създаде смислен string, който да върне. Споменах, че някои стандартни прототипи определят своя собствена версия на toString, така че те могат да създадат string, който съдържа по-полезна информация, отколкото "[object Object]".

Това е прост пример за мощна идея. Когато част от кода е написан за работа с обекти, които имат определен интерфейс (в този случай toString метод) всякакъв тип обекти, които се случва да подкрепят този интерфейс, могат да бъдат включени в кода и той просто ще работи.

Тази техника се нарича polymorphism (полиморфизъм) - въпреки, че не извършва реална смяна на формата. Полиморфния код може да работи със стойности на различни форми, стига да може интерфейса да ги подържа.

Изграждане на таблица

Ще работим върху леко по-ангажиращ пример, като се опитам да ви дам по-ясна представа, какво е полиморфизъм и обектно-ориентирано програмиране, като цяло. Проекта е : да се опитаме да напишем програма, която по даден масив от масиви с клетки на таблица, се изгражда string, който съдържа добре изглеждаща таблица - което означава, че колоните са прави и редовете са подравнени. Нещо такова:

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

Начинът на изграждане на системата на нашата таблица е, че функцията конструктор ще попита всяка клетка, колко е широка и висока и след това ще използва тази информация, за да определи ширината на колоните и височината на редовете. Тогава изграждащата функция, ще поиска клетките, за да изготви правилния размер и монтаж от резултите в един string.

Програмата за оформление ще комуникира с клетъчните обекти, чрез добре дефиниран интерфейс. По този начин, видовете клетки, които програмата поддържа не са фиксирани предварително. Можем да добавим нови стилове-клетки по-късно, например, подчертани клетки за заглавия на таблицата и ако те поддържат нашия интерфейс просто ще работят, без да изискват промени в програмата за оформление.

Това е интерфейса:

Ще направя тежки за използване по-високо ниво методи за масиви в този пример, тъй като той се поддава добре на този подход.

В първата част на програмата изчислява масиви от минималната ширина на колоните и височината на редовете за мрежата от клетки. Променливата 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 първо преобразува клетките обекти в ред блокове, които са масиви от strings, представляващи съдържанието на клетките, разделени с линия. Една единствена клетка, съдържаща просто броя 3776 може да бъде представена от масив с един елемент, като ["3776"], докато подчертана клетка може да отнеме две позиции и ще бъде представена от масива, като ["name", "----"].

Блоковете за row , които имат една и съща височина, трябва да се появят един до друг в крайната продукция. Второто извикване на map в drawRow изгражда на изхода ред по ред мапнатите редици в лявата страна на полето и за всяка от тях събира позиции от помеждутъци докато запълни ширината на таблицата. Тези позиции после се свързват с характерите за нов ред за предоставяне на целия ред, като върната стойност на drawRow.

Функцията drawLine извлича редовете, които трябва да появят един до друг от масива с блокове и свързва към тях характер за интервал, за да създаде един характер разстояние между колоните на таблицата.

Сега нека напишем конструктора за клетки, които съдържат текст, който реализира интерфейса на клетките в таблицата. Конструктора разделя string-а в масив от позиции, използвайки метода split, който реже string-а при всяка поява на неговия аргумент и връща масив от парчета. Методът 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, която изгражда даден string чиято стойност е аргумента на този string повторен times броя пъти. Метода 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));
// → ##    ##    ##
//      ##    ##
//   ##    ##    ##
//      ##    ##
//   ##    ##    ##

Работи! Но тъй като всички клетки са с един и същи размер, кода за оформление на таблицата, не прави нищо интересно.

Данните за таблицата на планините, които се опитваме да изградим, са налични в промеливата MOUNTAINS в пясъчника, а също и във файл за сваляне от сайта.

Ние искаме да се подчертаем най-горния ред, който съдържа имената на колоните, като акцент върху клетките, с поредица от тирета характери. Няма проблем - просто ще напишем типа на клетките, които обработват подчертването.

function UnderlinedCell(inner) {
  this.inner = inner;
}
UnderlinedCell.prototype.minWidth = function() {
  return this.inner.minWidth();
};
UnderlinedCell.prototype.minHeight = function() {
  return this.inner.minHeight() + 1;
};
UnderlinedCell.prototype.draw = function(width, height) {
  return this.inner.draw(width, height - 1)
    .concat([repeat("-", width)]);
};

Подчертаната клетка съдържа друга клетка. Тя съобщава своя минимален размер, същия, като този на нейната вътрешна клетка (чрез извикване на методите minWidth и minHeight към тази клетка) но добавя само една към височината за да отчете, че пространството е заето от подчертаването.

Чертането на такава клетка е много просто - вземаме съдържанието на вътрешната клетка и залепяме един ред с тирета към него.

Имайки вече подчертаващия механизъм, сега можем да напишем функция, която изгражда мрежа от клетки с нашия набор от данни.

function dataTable(data) {
  var keys = Object.keys(data[0]);
  var headers = keys.map(function(name) {
    return new UnderlinedCell(new TextCell(name));
  });
  var body = data.map(function(row) {
    return keys.map(function(name) {
      return new TextCell(String(row[name]));
    });
  });
  return [headers].concat(body);
}

console.log(drawTable(dataTable(MOUNTAINS)));
// → name         height country
//   ------------ ------ -------------
//   Kilimanjaro  5895   Tanzania
//   … etcetera

Стандартната функция Object.keys връща масив с имена на свойства в даден обект. Най-горният ред на таблицата трябва да съдържа подчертаните клетки, които дават имената на колоните. По-долу са стойностите от всички обекти в масива от данни и се показват, като нормални клетки - ние ги извличаме мапнати от keys масива, така че да сме сигурни, че последователноста на клетките е една и съща във всеки ред.

Получената таблица наподобява примера, показан преди, но без да прави дясно подравняване на данните в height колоната. Ще стигнем до това след малко.

Getters and setters

При определяне на интерфейс е възможно да се включат свойства, които не са методи. Можехме да дефинираме minHeight и minWidth просто, като поддържащи номера. Но тогава трябваше да ги изчислим в конструктора, който добавя не строго свързан с изграждането на обекта код. Това ще предизвика проблеми, ако например вътрешната клетка на подчертаната клетка се промени, при което и размера на подчертаната клетка също трябва да се промени.

Това е доведе някои хора да приемат принципа, никога да не включват nonmethod свойства в интерфейси. Вместо да имат директен достъп до свойство на някаква стойност, те използват getSomething и setSomething методи, за да четат и пишат свойството. Този подход има недостатък, че можете да се окажете пред писане и четене на много допълнителни методи.

За щастие JavaScript осигурява техника, която взема най-доброто от двата свята. Ние можем да уточним свойства, които от външна страна изглеждат, като нормални свойства но имат скрити методи свързани с тях.

var pile = {
  elements: ["eggshell", "orange peel", "worm"],
  get height() {
    return this.elements.length;
  },
  set height(value) {
    console.log("Ignoring attempt to set height to", value);
  }
};

console.log(pile.height);
// → 3
pile.height = 100;
// → Ignoring attempt to set height to 100

В един обект, нотацията get и set за свойства ви позволява да определите функция, която да се изпълнява, когато свойството се чете или пише. Можете да добавите такова свойство към съществуващ обект, например прототип, използвайки функцията Object.defineProperty (която използвахме за създаване на nonenumerable свойства).

Object.defineProperty(TextCell.prototype, "heightProp", {
  get: function() { return this.text.length; }
});

var cell = new TextCell("no\nway");
console.log(cell.heightProp);
// → 2
cell.heightProp = 100;
console.log(cell.heightProp);
// → 2

Вие можете да използвате подобно set свойство в обект подаден към defineProperty, като определите setter метод. Когато има getter, но няма дефиниран setter, писането на свойство просто се игнорира.

Наследяване

Още не сме приключили с нашето упражнение за оформление на таблица. Не сме оправили оформлението на дясно с подравняването на колоната от цифри. Трябва да създадем друг тип клетка, която е подобна на TextCell, но вместо да попълним позициите от дясната страна ги попълним от ляво, така че да се подравнят от дясно.

Бихме могли да напишем изцяло нов конструктор с трите метода в неговия прототип. Но прототипите могат да имат прототипи и това ни позволява да направим нещо умно.

function RTextCell(text) {
  TextCell.call(this, text);
}
RTextCell.prototype = Object.create(TextCell.prototype);
RTextCell.prototype.draw = function(width, height) {
  var result = [];
  for (var i = 0; i < height; i++) {
    var line = this.text[i] || "";
    result.push(repeat(" ", width - line.length) + line);
  }
  return result;
};

Ние повторно използваме конструктора с методите minHeight и minWidth от регулиращия TextCell. Сега RTextCell е еквивалентен на TextCell с изключение на това, че метода draw съдържа различна функция.

Този модел се нарича inheritance (наследяване). Той ни позволява да изградим малко по различни типове данни от съществуващите типове данни с относително малко работа. Обикновено новия конструктор извиква стария конструктор (с помощта на call метод, за да бъде в състояние да предаде на новия обект своята this стойност). След като е бил извикан този конструктор, можем да приемем, че всички области, които се съдържат в стария тип обект са добавени в новия. Ние определяме прототип на конструктора, който да извлича от стария прототип, така че всички инстанции на този тип да имат достъп до свойствата в прототипа. И накрая, можем да заменим някои от тези свойства, като ги добавим към нашия нов прототип.

Сега, ако леко коригираме функцията dataTable, като използваме RTextCell за клетките, чиято стойност са номера, ще получим таблицата, към която се стремим.

function dataTable(data) {
  var keys = Object.keys(data[0]);
  var headers = keys.map(function(name) {
    return new UnderlinedCell(new TextCell(name));
  });
  var body = data.map(function(row) {
    return keys.map(function(name) {
      var value = row[name];
      // This was changed:
      if (typeof value == "number")
        return new RTextCell(String(value));
      else
        return new TextCell(String(value));
    });
  });
  return [headers].concat(body);
}

console.log(drawTable(dataTable(MOUNTAINS)));
// → … beautifully aligned table

Наследяването е основна част от обектно-ориентираното програмиране, наред с капсулиране и полиморфизъм. Но докато последните две обикновено се считат за чудесни идеи, наследяването е малко спорно.

Основната причина за това е, че то често се бърка с полиморфизъм, използван, като по-мощен инструмент отколкото е в действителност и впоследствие във всички видове грозен начин. Като се има в предвид, че капсулиране и полиморфизъм могат да се използват за отделяне на части от код от друг код, намалявайки заплитането на цялостната програма, то наследяването фундаментално свързва типове заедно създавайки по-голямо заплитане.

Можете да имате полиморфизъм без наследяване, както видяхте. Аз не ви казвам да избягвате изцяло наследяването, аз го използвам редовно в моите собствени програми. Но вие трябва да го видите, като леко завъртян трик, който може да ви помогне да определите нови типове с малко код, а не като основен принцип за организиране на кода. Предпочитан начин за разширяване на типовете е чрез композиция, като например, както 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 за създаване на нови обекти. Прототипа на новия обект ще бъде обект намерен в свойствата на прототипа на функцията конструктор. Можете да използвате добре това, чрез поставяне на всички свойствата, както и всички стойности за даден тип и ги споделите в техния прототип. Операторът instanceof може да бъде използван върху даден обект и конструктор, за да ви каже дали този обект е инстанция на този конструктор.

Едно полезно нещо, което може да се направи за обекти, е да им определите интерфейс и да кажете на всички, че трябва да общуват с вашия обект само чрез този интерфейс. Останалата част от детайлите, които изграждат вашия обект са капсулирани, скрити зад интерфейса.

Като говорим от гледна точка на интерфейса, дали само един тип обект може да се приложи към този интерфейс? Ако имате различни обекти изложени в този интерфейс и напишете код, който да работи с всеки обект от интерфейса, се нарича полиморфизъм. Това е много полезно.

При изпълнението на множество типове, които се различават само в някои детайли, може да бъде полезно просто да се направи прототип на новия тип, който произтича от прототипа на стария тип и вашия нов конструктор да комуникира със стария. Това ви дава тип обект подобен на стария, но в който може да добавяте или заменяте свойства, както намерите за добре.

Упражнения

Тип вектор

Напишете конструктор Vector, който представлява вектор в двумерно пространство. Той има x и y параметри (номера), които трябва да запази в свойства със същото име.

Дайте на прототипа на Vector два метода plus и minus, които вземат друг вектор, като параметър и връщат нов вектор, който съдържа сумата или разликата на двата вектора - x и y стойности.

Добавете getter свойство към прототипа, което изчислява дължината на вектора, тоест разстоянието на точка (x, y) от началното (0, 0).

// Your code here.

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

Вашето решение може тясно да следва модела на Rabbit конструктора от тази глава.

Добавянето на getter свойство към конструктора може да се направи с Object.defineProperty функцията. За да изчислите разстоянието от (0, 0) до (x, y) можете да използвате Питагоровата теорема, която казва, че квадрата на разстоянието, което търсим е равно на квадрата на х-координатата плюс квадрата на y- координатата. По този начин, √(x2 + y2) е номера, който искаме, а Math.sqrt е начина по който се изчислява корен квадратен в JavaScritp.

Друга клетка

Прилагането на клетъчен тип с име StretchCell(inner, width, height) който съответства на интерфейса на клетка от таблица е описан по рано в тази глава. Тя трябва да увива друга клетка (както UnderlinedCell прави) и да гарантира, че получената клетка има поне дадената width и height, дори ако вътрешната клетка е по-малка.

// Your code here.

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", "   "]

Ще трябва да се съхраняват всичките три аргумента на конструктора в обекта на инстанцията. Методите minWidth и minHeight трябва да се извикат чрез съответните методи в inner клетката, но като се гарантира, че не връщат номер по-малък от определения размер (евентуално с помощта на Math.max).

Не забравяйте да добавите draw метод, който просто препраща извикване към вътрешната клетка.

Sequence интерфейс

Направете дизайн на интерфейс, който абстрактно минава над колекция от стойности. Обект, който предоставя такъв интерфейс представлява поредица от последователности и интерфейса по някакъв начин трябва да направи възможно за код, който използва такъв обект за обхождане на последователности, да търси стойностите на елемент, от които е съставен и да намери начин да разбере, когато стигне края на поредицата.

Когато сте определили вашия интерфейс, се опитайте да напишете функция logFive, която взема обекта от последователности и призовава console.log върху първите пет елемента или по-малко ако поредицата има по-малко от пет елемента.

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

// Your code here.

logFive(new ArraySeq([1, 2]));
// → 1
// → 2
logFive(new RangeSeq(100, 1000));
// → 100
// → 101
// → 102
// → 103
// → 104

Един от начините за решаване на това е да се даде последователността на обектното състояние, което означава, че техните свойства се променят в процеса на използването им. Можете да съхранявате брояч, който да показва напредъка на последователноста на обекта.

Вашият интерфейс ще трябва да се изложи най-малко един път, за да получи следващия елемент и да разбере дали итерацията не е достигнала края на поредицата все още. Изкушаващо е там да се търкаля още един метод next, който да връща null или undefined, когато поредицата е в своя край. Но ще имате проблем, ако поредицата съдържа null. Така че за предпочитане е отделен метод (или getter свойство), за да разберете дали е достигнат края.

Друго решение е да се избегне промяната на състоянието в обекта. Можете да изложите метод за получаване на текущия елемент (без напредъка на брояч) и друг за получаване на нова последователност, която представлява останалите елементи след текущия (или специална стойност, ако е достигнат края на поредицата). Това е доста елегантна последователност на стойност, която ще "остане себе си" дори след като се използва и по този начин може да се споделя с друг код, без да се притеснявате за това, което може да се случи с нея. Това за съжаление, също е донякъде неефективно в езика на JavaScript, тъй като включва създаването на много обекти по време на итерация.