Klasyczne klasy w JavaScript

JavaScript nie oferuje klasycznej obiektowości, wszystko opiera się tutaj na prototypach. Nie ma słówka kluczowego Class i tak dalej. Tego typu pozorna niedogodność, ale przede wszystkim chęć organizacji kodu, wymusza czasem zastosowanie pewnych, dodatkowych narzędzi. Jednym z nich jest abstrakcja klas. Ma to mootools, Prototype, są odpowiednie pluginy do jQuery. O co chodzi?

Wyobraźmy sobie, że nie odpowiada nam dzielenie kodu na konstruktory i rozszerzanie ich prototypów:

var Animal = function(age) {
	this.age = age;
};
Animal.prototype.getAge = function() { return this.age; };
Animal.prototype.setAge = function(age) { this.age = age; };
var Dog = function(age) {
	this.setAge(age);
};
Dog.prototype = new Animal; // dziedziczymy po Animal
Dog.prototype.constructor = Dog;
Dog.prototype.getVoice = function() { return "hau hau"; }

Używamy tego w ten sposób:

var dog = new Dog(10);
dog.getVoice(); // hau hau
dog.getAge(); // 10

W przypadku dużych i rozbudowanych systemów może rodzić to pewne kłopoty z utrzymaniem i czystością kodu. W związku z tym powstały abstrakcje klas. Stosując jedną z nich, można by napisać tak:

var Animal = new Klass({
	Init: function(age) {
		this.setAge(age);
	},
	age: null,
	getAge: function() { return this.age; },
	setAge: function(age) { this.age = age; }
});

var Dog = new Klass({
	Extends: Animal,
	getVoice: function() { return "hau hau"; }
});

Powiedzmy sobie szczerze – jest to bardziej przejrzyste rozwiązanie, pozwalające na stworzenie lepszej dokumentacji, testów i tak dalej. Jest też, sądzę, łatwiejsze do zrozumienia dla developerów nie mających zbyt dużego rozeznania w JavaScript. Zobaczmy więc, jak można coś takiego łatwo stworzyć.

Pierwsze, co jest nam potrzebne, to odpowiedni konstruktor Klass.

var Klass = (function() {
// ...
})();

Jak widać, w tym momencie Klass ma wartość undefined – to jasne, ponieważ funkcja nie mająca zdefiniowanego return zwraca undefined. Jako że mamy zamiar używać Klass w ten sposób – new Klass({}); – wiemy, że pod Klass musi znajdować się jakiś konstruktor, przyjmujący argument, który będzie zawierał pola i metody przyszłych klas. Szybka korekta i możemy iść dalej:

var Klass = (function() {
	return function(classDefinition) {};
})();

Dobrze, pora zająć się metodami Init, uruchamiającymi się podczas konstruowania przyszłych obiektów z naszych klas. Zdefiniujmy sobie zmienną dummyClass, która będzie z kolei konstruktorem-funkcją. Będziemy dekorować ją potem atrybutami z classDefinition.

var Klass = (function() {
	return function(classDefinition) {
		var dummyClass = function() {
			this.Init && typeof this.Init === "function" && this.Init.apply(this, arguments);	
		};
	
		return dummyClass;
	};
})();

Co dzieje się w dummyClass? Sprawdzamy po kolei, czy po wywołaniu new JakasNaszaKlasa istnieje coś takiego jak metoda Init, która ma pełnić rolę konstruktora. Następnie upewniamy się, że jest to funkcja, by potem odpalić ją poprzez this.Init.apply(this, arguments).

Nasza dummyClass musi jednak jakoś wiedzieć o Init, którą możemy podać w classDefinition. Zresztą, tak jak o innych polach i metodach naszych przyszłych klas. Najlepiej będzie więc przepisać wszystko z classDefinition do prototypu dummyClass:

var Klass = (function() {
	return function(classDefinition) {
		var dummyClass = function() {
			this.Init && typeof this.Init === "function" && this.Init.apply(this, arguments);	
		};

		for (var member in classDefinition) {
			if (classDefinition.hasOwnProperty(member)) {
				dummyClass.prototype[member] = classDefinition[member];
				classDefinition[member] = null;
			}
		}
		return dummyClass;
	};
})();

Zrobiliśmy to prostą iteracją przez wszystkie klucze classDefinition, a więc w wypadku klasy Animal iterujemy przez:

Init: function(age) {
	this.setAge(age);
},
age: null,
getAge: function() { return this.age; },
setAge: function(age) { this.age = age; }

Sprawdźmy, czy nasz dotychczasowy kod działa:

var Animal = new Klass({
	Init: function(age) { this.age = age; },
	age: null
});
var zwierze = new Animal(10);
alert(zwierze.age); // 10

Jest fajnie, ale brakuje czegoś istotnego. Oczywiście chodzi o dziedziczenie, które zadziała, kiedy podamy w definicji klasy słówko Extends.

var Klass = (function() {
	return function(classDefinition) {
		var dummyClass = function() {
			this.Init && typeof this.Init === "function" && this.Init.apply(this, arguments);	
		};

		if (typeof classDefinition.Extends === "function") {}
		delete classDefinition.Extends;

		for (var member in classDefinition) {
			if (classDefinition.hasOwnProperty(member)) {
				dummyClass.prototype[member] = classDefinition[member];
				classDefinition[member] = null;
			}
		}
		return dummyClass;
	};
})();

Umiejętnie rozpoznaliśmy sytuację, kiedy należy rozszerzyć klasę potomną o klasę bazową. Następnie usunęliśmy wartość Extends, by nie była kopiowana w pętli, chociaż to od nas zależy. Równie dobrze można by ją zostawić.

Wiemy, że musimy rozszerzyć dummyClass o classDefinition.Extends. Automatycznie myślimy więc o prototypach, które należy przekazać do classDefinition. Można więc zrobić tak:

dummyClass.prototype = classDefinition.Extends.prototype

Jak wiemy, wartość .prototype jest referencją do obiektu. Jeśli więc coś zmieni się w prototypie classDefinition.Extends, zmieni się również w prototypie dummyClass.

Słowem, dwa różne obiekty korzystałyby z tego samego prototypu, co może wywołać wiele zagrożeń. Zaradzić temu można w ten sposób:

var Klass = (function() {
	return function(classDefinition) {
		var dummyClass = function() {
			this.Init && typeof this.Init === "function" && this.Init.apply(this, arguments);	
		};

		if (typeof classDefinition.Extends === "function") {
			var temp = function() {};
			temp.prototype = classDefinition.Extends.prototype;
			dummyClass.prototype = new temp;
			temp = null;
		}
		delete classDefinition.Extends;

		for (var member in classDefinition) {
			if (classDefinition.hasOwnProperty(member)) {
				dummyClass.prototype[member] = classDefinition[member];
				classDefinition[member] = null;
			}
		}
		return dummyClass;
	};
})();

I tak właśnie powstała dość funkcjonalna fabryczka klas, choć z wieloma ograniczeniami, które z pewnością łatwo dostrzeżecie. Brakuje, co ważne, metody na wzór super(), która odnosiłaby się do analogicznych metod z klasy bazowej. Zadanie to postaram opisać się później. Temat poruszę też prawdopodobnie także na następnym szkoleniu.

Na koniec wypada przetestować nasz kod:

var Animal = new Klass({
	Init: function(age) {
		this.setAge(age);
	},
	age: null,
	getAge: function() { return this.age; },
	setAge: function(age) { this.age = age; }
});

var Dog = new Klass({
	Extends: Animal,
	getVoice: function() { return "hau hau"; }
});

var pies = new Dog(10);
pies.getVoice(); // "hau hau";
pies.getAge(); // 10
pies.setAge(100);
pies.getAge(); // 100
pies instanceof Animal; // true
pies instanceof Dog; // true

Komentarze

1

W pierwszym listingu niebardzo rozumiem czemu this.age=null a nie this.age=age. Zwierzę chyba zawsze ma wiek, nie? Dlaczego tylko pies miałby mieć a ogólne zwierzę nie?

W drugim listingu natomiast zdaje mi się że brakuje linii Dog.prototype.constructor=Dog, która może być potrzebna gdyby ktoś chciał sprawdzić jakiej klasy obiekt ma przez sprawdzenie jego this.constructor.

2

Eh, kiedys chcialem byc jak porneL w HTML/CSS. Teraz chce byc taki jaki ty w Javascript ;)

3

Co do 1. racja, poprawilem, przeoczenie. Co do drugiego – i tak i nie. Nie skupialem sie za bardzo na przykladzie w czystym JS, ale dla swietego spokoju dodalem te linie. Dzieki za komentarz.

4

Można prosić o zaimplementowanie podświetlania składni na blogu? ;)

5

made in Szczecin ;)

6

W linii Dog.prototype=new Animal musimy stworzyć zwierzę, żeby jego metodami, własnymi oraz z prototypu, zapełnić prototyp psa. Rozwiązanie to ma tę wadę, że wymaga stworzenia instancji klasy jakby abstrakcyjnej, nie przekazując parametrów do konstruktura, a przecież gdyby konstruktor zwierzęcia sprawdzał poprawność parametrów to nie byłoby to w ogóle możliwe.

Nie należy zrobić Dog.prototype=Animal.prototype, ale czy można zamiast tego zastosować taki trik, jaki jest w przedostatnim listingu? Czy takie rozwiązanie miałoby jakieś istotne wady?

7

Zasadniczo nie widze powaznych wad, oprocz tego ze kod jest dosc nieczytelny. Dosc podobnie robi Douglas Crockford:

http://javascript.crockford.com/prototypal.html

8

Chciałbym prosić o dowyjaśnienie jednego aspeku:

jaką przewagę ma powyższy wzór nad znacznie IMO czytelniejszym, typu.:
var Klass = (function() {

return {…}
});
var ob=new Klass();

też można rozszerzać “klasę” poprzez prototype, dodać konstruktor itp.
a dodatkowo, klasa nie jest autouruchamiana (brak nawiasów zaraz za klamrą funkcji/klasy) jeśli nie zadeklaruję żadnego dla niej obiektu.

Pozdrawiam i dzięki za najlepszy blog JS po polsku!

Tomasz B.
9

jaką przewagę ma powyższy wzór nad znacznie IMO czytelniejszym, typu.:

Czyli ktory? ;-)

10

Chodzi mi o ten omówiony w artykule sposób tworzenia “klasy”:
że klasa jest funkcją, która zwraca funkcję, która zwraca funkcje:

var Klass = (function() {
return function(classDefinition) {
var dummyClass = function() {

};
}
return dummyClass;
})();

i że ma ona ten nawias za definicją “()”, który AFAIK powoduje jej automatyczne wykonanie (jak w singletonie)

W porównaniu do sposobu, który podałem wyżej, gdzie klasa – funkcja zwraca obiekt (z metodami, polami, itp) i nie jest automatycznie uruchamiana (brak nawiasów).

Może za bardzo nakręciłem, więc przepraszam.
Jest też możliwość, że oba sposoby są tym samym, a ja tego nie widzę :-)

Tomasz B.
11

Nie widzę potrzeby tworzenia tej anonimowej funkcji pośredniej temp.
Wszystkie funkcje i właściwosci z classDefinition:object zostaną przepisane do egzemplarza temp:function a mogłyby być przepisane do egzemplarza funkcji wskazanej przez (classDefinition.Extends):function.
Róznica żadna – obiekt wskazany przez Extends.prototype i tak jest wspóldzielony dla wszystkich wywolań Klass z tą sama funkcją nadklasy wskazaną poprzez classDefinition.Extends.

1)

var temp = function() {};
temp.prototype = classDefinition.Extends.prototype;
dummyClass.prototype = new temp;
temp = null;

((dummyClass.prototype == (new temp)).constructor == temp).prototype == Extends.prototype;

2)

dummyClass.prototype = new (classDefinition.Extends);

((dummyClass.prototype == (new (classDefinition.Extends))).constructor == classDefinition.Extends).prototype == Extends.prototype;

oLi
12

oLi:

Idac Twoim tokiem rozumowania zawsze bedzie wywolywany konstruktor (funkcja Init) z classDefinition.Extends, a tego nie chcemy. Np. kiedy w Init bedzie jakis alert, czy cos, wtedy pojawia sie problemy.

Tomasz:

Self-invoking function pomagaja nam zachowan porzadek w kodzie – nie zanieczyszcza przypadkowo scope.

13

Owszem, ale cytuję:

Jak wiemy, wartość .prototype jest referencją do obiektu. Jeśli więc coś zmieni się w prototypie classDefinition.Extends, zmieni się również w prototypie dummyClass.
Słowem, dwa różne obiekty korzystałyby z tego samego prototypu, co może wywołać wiele zagrożeń. …

Czyli mowa była o wspołdzieleniu obiektu będącego prototypem co zasadniczo jest zaletą a nie wadą.

Zaradzić wykonaniu funkcji będącej konstruktorem można w prosty sposób:

Klass.EXTENDS = {};

dummyClass.prototype = new (classDefinition.Extends)(Klass.EXTENDS);

var dummyClass = function() {
arguments[0] != Klass.EXTENDS && this.Init && typeof this.Init === “function” && this.Init.apply(this, arguments);
};

oLi
14

Dochodzimy do tego samego – Twoj sposob robi dokladnie to co funkcja pomocnicza, ale ja wole swoja temp, chocby dlatego ze latwiej bedzie zrobic obsluge this._super() ;-)

Dzieki za fajny komentarz!

15

Ciekawy artykuł. Dobry materiał do włączenia do szkoleń. Proponuję dodać numerowanie listingów.

Piotr Dobrogost
16

Czy jest sens stosowania nazewnictwa wprowadzającego programistę w poczucie (błąd?), że JS dostarcza klas?

Czy nie lepiej po prostu używać wprost fabryki obiektów?

Ciekaw jestem, co na ten temat uważasz. Z tego co czytałem i widziałem to choćby linkowany wcześniej Crockford lubi wzorzec fabryki obiektów / modułu.

17

To jest tylko przykład, że da się zrobić taką emulację (plus funkcja super, której tu nie ma). Sam jestem fanem fabryki obiektow, o ktorej napisze w wolnej chwili w serii dot. architektury.

18

Zauważyłem, że Crockford często używa określenia “wzorzec modułu”. Jak na moje jest to to samo, co wzorzec fabryki. Czy może się mylę?

Przez wzorzec fabryki, ku ścisłości, rozumiem coś takiego [ http://www.yarpo.pl/2011/01/11/wzorzec-fabryki-obiektow-w-js/ ].
Jak można przeczytać w Mocnych stronach “Moduł jest to funkcja (lub obiekt), która prezentuje pewien interfejs, ale ukrywa (…) implementację”.

Z tego rozumiem, że z nieznanych mi przyczyn Crockford wprowadza pojęcie wzorca modułu w miejsce powszechnie znanego i rozpoznawalnego wzorca fabryki obiektów. A może jest w tym głębsza myśl, której nie obejmuję swoim małym umysłem ;P ?

Dodaj komentarz

Dozwolone tagi: <blockquote>, <code>, <strong>