Глава 6Тайният живот на обектите
Проблемът с обектно - ориентираните езици е, че те имат всичко, скрито в средата, която носят със себе си. Ти искаш банан, но това което получаваш е горила държаща банан и цялата джунгла”
Когато един програмист казва “обект”, това е препълнен термин. В моята професия, обектите са начин на живот, предмет на свещени войни, обичана модерна дума, която все още не е загубила своята сила.
За външен човек, това е може би малко объркващо. Нека да започнем с кратка история на обектите, като програмна конструкция.
История
Тази история, както повечето истории за програмиране, започва с проблема за сложността. Една философия казва, че сложността може да се направи лесно управляема, чрез разделянето и на малки отделения, които са изолирани едно от друго. Тези отделения, са били наречени objects.
Един обект е твърда черупка, която крие лепкава сложност в нея и вместо това ни предлага няколко копчета и съединители (като методи), които предоставят интерфейс, чрез който обекта може да се използва. Идеята е интерфейса да е сравнително прост, а всички сложни неща, които се случват в обекта да бъдат игнорирани, когато се работи с него.
Като пример, можете да си представите един обект, който осигурява интерфейса на вашия екран. Той осигурява начини да се чертаят фигури и текст в пространството, но крие всички детайли, как тези форми се превръщат в реални пиксели на вашия екран. Ще имаме набори от методи, като например 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
, като вид фон, където могат да се разгледат свойствата, които не се намират в самия обект.
Приоритетни свойства, които съществуват в прототипа е често полезно нещо, което може да се направи. Примера със заешките зъби показва, че може да се използва за изразяване на изключителни свойства в случаи на по-общ клас от обекти, като същевременно позволява на 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.
Програмата за оформление ще комуникира с клетъчните обекти, чрез добре дефиниран интерфейс. По този начин, видовете клетки, които програмата поддържа не са фиксирани предварително. Можем да добавим нови стилове-клетки по-късно, например, подчертани клетки за заглавия на таблицата и ако те поддържат нашия интерфейс просто ще работят, без да изискват промени в програмата за оформление.
-
minHeight()
връща число, показващо минималната височина, която клетката изисква (в реда). -
minWidth()
връща число, показващо минималната ширина на тази клетка (в характери). -
draw(width, height)
връща масив с дължинаheight
, който съдържа серия от strings, които са ширината на всеки характер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
първо преобразува клетките обекти в ред блокове, които са масиви от 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, тъй като включва създаването на много обекти по време на итерация.