24 Ocak 2022

Sınıf kalıtımı, super

Sınıflar başka sınıfları genişletebilirler. Bunun için prototip kalıtımı tabanlı güzel bir yazılışı bulunmaktadır.

Diğer bir sınıftan kalıtım sağlamak için "extends" ile belirtmek gerekmektedir.

Aşağıda Animal’dan kalıtım alan Rabbit sınıfı gösterilmektedir:

class Animal {

  constructor(name) {
    this.speed = 0;
    this.name = name;
  }

  run(speed) {
    this.speed += speed;
    alert(`${this.name} runs with speed ${this.speed}.`);
  }

  stop() {
    this.speed = 0;
    alert(`${this.name} stopped.`);
  }

}

// Inherit from Animal
class Rabbit extends Animal {
  hide() {
    alert(`${this.name} hides!`);
  }
}

let rabbit = new Rabbit("White Rabbit");

rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.hide(); // White Rabbit hides!

extends kelimesi aslında Rabbit.prototype’dan referans alıp bunun [[Prototype]]'ını Animal.prototype’a ekler. Aynen daha önce de gördüğümüz gibi.

Artık rabbit hem kendi metodlarına hem de Animal metodlarına erişebilir.

extends’ten sonra her türlü ifade kullanılabilir.

Extends’ten sonra sadece sınıf değil her türlü ifade kullanılabilir.

Örneğin, üst sınıfı yaratan yeni bir fonksiyon çağrısı:

function f(phrase) {
  return class {
    sayHi() { alert(phrase) }
  }
}

class User extends f("Hello") {}

new User().sayHi(); // Hello

Burada class User f("Hello")'nun sonucunu kalıtır.

Bu belki çok ileri teknik programlama kalıpları için kullanışlı olabilir. Böylece birçok koşula göre fonksiyonları kullanarak farklı sınıflar oluşturabilir ve bunlardan kalıtım alınabilir.

Bir metodu geçersiz kılma, üstüne yazma.

Şimdi biraz daha ileri gidelim ve metodun üstüne yazalım. Şimdiden sonra Rabbit stop metodunu kalıtım alır, bu metod this.speed=0’ı Animal sınıfında ayarlamaya yarar.

Eğer Rabbit içerisinde kendi stop metodunuzu yazarsanız buna üstüne yazma denir ve Animal’da yazılmış stop metodu kullanılmaz.

class Rabbit extends Animal {
  stop() {
    // ... rabbit.stop() için artık bu kullanılacak.
  }
}

…Fakat genelde üst metodun üzerine yazmak istenmez, bunun yerine küçük değişiklikler yapmak veya fonksiyonliteyi genişletmek daha fazla tercih edilen yöntemdir. Metodda birçeyler yapar ve genelde bundan önce/sonra veya işlerken üst metodu çağırırız.

Sınıflar bunun için "super" anahtar kelimesini sağlarlar.

  • super.method(...) üst class’ın metodunu çağırmak için.

  • super(...) üst metodun yapıcısını (constructor) çağırmak için kullanılır.

Örneğin, Rabbit otomatik olarak durduğunda gizlensin.

class Animal {

  constructor(name) {
    this.speed = 0;
    this.name = name;
  }

  run(speed) {
    this.speed += speed;
    alert(`${this.name} runs with speed ${this.speed}.`);
  }

  stop() {
    this.speed = 0;
    alert(`${this.name} stopped.`);
  }

}

class Rabbit extends Animal {
  hide() {
    alert(`${this.name} hides!`);
  }

  stop() {
    super.stop(); // üst sınıfın stop metodunu çağır.
    this.hide(); // sonra gizle
  }
}

let rabbit = new Rabbit("White Rabbit");

rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.stop(); // White Rabbit stopped. White rabbit hides!

Artık Rabbit, stop metodunda üst sınıfın super.stop()'unu çağırmaktadır.

Ok fonksiyonlarının super’i bulunmamaktadır.

Ok fonksiyonları bölümünde bahsedildiği gibi, ok fonksiyonlarının super’i bulunmamaktadır.

Eğer erişim olursa bu super dışarıdaki fonksiyonundur. Örneğin:

class Rabbit extends Animal {
  stop() {
    setTimeout(() => super.stop(), 1000); // üst'ün stop'unu 1 sn sonra çağır.
  }
}

Ok fonksiyonu içerisindeki super ile stop() içerisine yazılan super aynıdır. Eğer “sıradan” bir fonksiyon tanımlarsak bu hataya neden olabilir:

// Unexpected super
setTimeout(function() { super.stop() }, 1000);

Yapıcı metodu ezmek.

Yapıcı metodlar ile yapılan şeyler biraz çetrefillidir.

Şimdiye kadar Rabbit kendisine ait yapıcı’ya sahipti.

Şartname’ye göre eğer bir sınıf diğer başka bir sınıftan türer ve constructor’a sahip değil ise aşağıdaki yapıcı otomatik olarak oluşturulur.

class Rabbit extends Animal {
  // yapıcısı olmayan ve türetilen sınıf için oluşturulur.
  constructor(...args) {
    super(...args);
  }
}

Gördüğünüz gibi aslında üst sınıfın yapıcı’sını tüm argümanları göndererek çağırır. Eğer kendimiz bir yapıcı yazmazsak bu meydana gelir.

Özel olarak uyarlanmış bir yapıcı oluşturalım. Bu isim ile birlikte earLength’i de tanımlasın:

class Animal {
  constructor(name) {
    this.speed = 0;
    this.name = name;
  }
  // ...
}

class Rabbit extends Animal {

  constructor(name, earLength) {
    this.speed = 0;
    this.name = name;
    this.earLength = earLength;
  }

  // ...
}

// Çalışmaz!
let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined.

Nasıl ya! Hata aldık. Şimdi de rabbit oluşturamıyoruz. Neden peki?

Kısa cevap: Türemiş sınıftaki yapıcı kesinlikle super(...) i çağırmalıdır. Bu this’den önce olmalıdır.

…Peki neden? Çok garip değilmi?

Tabi bu açıklanabilir bir olay. Detayına girdikçe daha iyi anlayacaksınız.

JavaScript’te “türeyen sınıfın yapıcı fonksiyonu” ve diğerleri arasında farklılıklar mevcuttur. Türemiş sınıflarda eş yapcıı fonksiyonlar içsel olarak [[ConstructorKind]]:"derived" şeklinde etiketlenir.

Farklılık:

  • Normal yapıcı çalıştığında boş bir objeyi this olarak yaratır ve bunun ile devam eder.
  • Fakat türemiş sınıfın yapıcısı çalıştığında bunu yapmaz. Üst fonksiyonun yapıcısının bunu yapmasını bekler.

Eğer kendimiz bir yapıcı yazarsak bundan dolayı super i çağırmamız gerekmektedir. Aksi halde this referansı oluşturulmaz ve biz de hata alırız.

Rabbit’in çalışabilmesi için this’den önce super() çağırılmalıdır.

class Animal {

  constructor(name) {
    this.speed = 0;
    this.name = name;
  }

  // ...
}

class Rabbit extends Animal {

  constructor(name, earLength) {
    super(name);
    this.earLength = earLength;
  }

  // ...
}

// Şimdi düzgün bir şekilde çalışır.
let rabbit = new Rabbit("White Rabbit", 10);
alert(rabbit.name); // White Rabbit
alert(rabbit.earLength); // 10

Super: dahililer, [[HomeObject]]

Artık super’in derinliklerine dalma vakti geldi. Altında yatan ilginç şeyler nelermiş bunları göreceğiz.

Öncelikle, şimdiye kadar öğrendiklerimizle super ile çalışmak mümkün değil.

Ever gerçekten, kendimize soralım, nasıl teknik olarak böyle bir şey çalışabilir? Bir obje metodu çalıştığında var olan objeyi this olarak alır. Eğer biz super.method()'u çağırırsak metod’u nasıl alabilir? Doğal olarak method’u var olan objenin prototipinden almak gerekmektedir. Peki teknik olarak bunu JavaScript motoru nasıl halledebilir?

Belki thisin [[Prototype]]'ını this.__proto__.method olarak alıyordur? Malesef böyle çalışmıyor.

Bunu test edelim. Sınıflar olmadan basit objelerle, fazladan karmaşıklaştırmadan deneyelim.

Aşağıda rabbit.eat(), kendisinin üst metodu animal.eat()'i çağırmalıdır:

let animal = {
  name: "Animal",
  eat() {
    alert(this.name + " eats.");
  }
};

let rabbit = {
  __proto__: animal,
  name: "Rabbit",
  eat() {
    // bizim tahminimze göre super.eat() bu şekilde çalışabilir.
    this.__proto__.eat.call(this); // (*)
  }
};

rabbit.eat(); // Rabbit eats.

(*) satırında animal prototipinden eat’i almakta ve var olan obje kaynağından çağırmaktayız. Dikkat edin burada .call(this) oldukça önemlidir. Çünkü basit this.__proto__.eat() üst eat’i prototipin kaynağı ile çağırır, var olan objenin değil.

Yukarıdaki kod beklendiği gibi çalışmaktadır. Doğru alert vermektedir.

Şimdi bu zincire bir tane daha obje ekleyelim. İşler nasıl bozuluyor görelim:

let animal = {
  name: "Animal",
  eat() {
    alert(this.name + " eats.");
  }
};

let rabbit = {
  __proto__: animal,
  eat() {
    // ...tavşan-stili ayla ve üst sınıfı çağır.
    this.__proto__.eat.call(this); // (*)
  }
};

let longEar = {
  __proto__: rabbit,
  eat() {
    // ...uzun kulaklar ile bir şeyler yap ve üst sınıfı çağır.
    this.__proto__.eat.call(this); // (**)
  }
};

longEar.eat(); // Error: Maximum call stack size exceeded

Yazdığınız kod artık çalışmıyor! longEar.eat()'i çağırırken hata olduğunu görebilirsiniz.

Bu çok açık olmayabilir, fakat longEar.eat() in hata kodlarını takip ederseniz nedenini anlayabilirsiniz. (*) ve (**) satırlarında this var olan (longEar) objesidir. Hatırlayın: Tüm objeclerin metodları this olarak var olan objeyi alır, prototipini değil.

Öyleyse, (*),(**) ve this.__proto__ tamamen aynıdır: rabbit. Hepsi rabbit.eat’i sonsuz zincire çıkmadan çağırır.

Aşağıda ne olduğunu daha iyi anlatan bir görsel bulunmakta:

  1. longEar.eat() içerisinde (**) satırı rabbit.eat’i this=longEar olarak çağırmakta.

    // longEar.eat() içerisinde this = longEar şeklinde kullanmaktayız.
    this.__proto__.eat.call(this) // (**)
    // olur
    longEar.__proto__.eat.call(this)
    // bu da
    rabbit.eat.call(this);
  2. Sonra rabbit.eat’in (*) satırı içerisinde bu zinciri daha üstlere çıkarmaya çalışıyoruz, fakat this=longEar, yani this.__proto__.eat yine rabbit.eat!

    // rabbit.eat() içerisinde thiss= longEar bulunmakta
    this.__proto__.eat.call(this) // (*)
    // olur
    longEar.__proto__.eat.call(this)
    // veya (yine)
    rabbit.eat.call(this);
  3. … Artık rabbit.eat 'in kendisini neden sonsuz defa çağırdığını görmüş olduk.

Problem sadece this kullanılarak çözülemez.

[[HomeObject]]

Buna bir çözüm sağlamak için, JavaScript fonksiyonlar için bir tane dahili özellik eklemiştir: [[HomeObject]]

Bir fonksiyon sınıf veya obje metodu olarak tanımlandığında, bunun [[HomeObject]]'i kendisi olur

Bu aslında bağımsız fonksiyonlar fikrini bozmaktadır, çünkü metodlar kendi objelerini hatırlamaktadır. Ayrıca [[HomeObject]] değiştirilemez, yani bu bağ sonsuza kadardır. Aslında bu dilde yapılan oldukça büyük bir değişiklik.

Fakat bu değişiklik güvenlidir. [[HomeObject]] sadece üst sınıfın metodlarını super’de çağırmaya yarar. Bundan dolayı uyumluluğu bozmaz.

Şimdi super ile nasıl çalışıyor bunu inceleyelim --tekrardan, sade objeleri kullanalım:

let animal = {
  name: "Animal",
  eat() {         // [[HomeObject]] == animal
    alert(this.name + " eats.");
  }
};

let rabbit = {
  __proto__: animal,
  name: "Rabbit",
  eat() {         // [[HomeObject]] == rabbit
    super.eat();
  }
};

let longEar = {
  __proto__: rabbit,
  name: "Long Ear",
  eat() {         // [[HomeObject]] == longEar
    super.eat();
  }
};

longEar.eat();  // Long Ear eats.

Her metod kendi objesinin [[HomeObject]] özelliğini hatırlamakta. Sonra superbunu üst objenin prototipini çözerken kullanır.

[[HomeObject]] sınıflar veya sade objeler’de tanımlanan metodlar için tanımlanır. Fakat objeler için, metodlar aynen şu şekilde tanımlanmalıdır: method(), "method:function()" şeklinde değil.

Aşağıdaki örnekte karşılaştırma için metod-olmayan yazım kullanılmıştır. [[HomeObject]] özelliği tanımlanmadı bundan dolayı da kalıtım çalışmayacaktır.

let animal = {
  eat: function() { // kısa yazım: eat() {...} olmalıdır.
    // ...
  }
};

let rabbit = {
  __proto__: animal,
  eat: function() {
    super.eat();
  }
};

rabbit.eat();  // super'i çalıştırırken hata oldu çünkü [[HomeObject]] bulunmamakta.

It works as intended, due to [[HomeObject]] mechanics. A method, such as longEar.eat, knows its [[HomeObject]] and takes the parent method from its prototype. Without any use of this.

Methods are not “free”

As we’ve known before, generally functions are “free”, not bound to objects in JavaScript. So they can be copied between objects and called with another this.

The very existance of [[HomeObject]] violates that principle, because methods remember their objects. [[HomeObject]] can’t be changed, so this bond is forever.

The only place in the language where [[HomeObject]] is used – is super. So, if a method does not use super, then we can still consider it free and copy between objects. But with super things may go wrong.

Here’s the demo of a wrong super call:

let animal = {
  sayHi() {
    console.log(`I'm an animal`);
  }
};

let rabbit = {
  __proto__: animal,
  sayHi() {
    super.sayHi();
  }
};

let plant = {
  sayHi() {
    console.log("I'm a plant");
  }
};

let tree = {
  __proto__: plant,
  sayHi: rabbit.sayHi // (*)
};

tree.sayHi();  // I'm an animal (?!?)

A call to tree.sayHi() shows “I’m an animal”. Definitevely wrong.

The reason is simple:

  • In the line (*), the method tree.sayHi was copied from rabbit. Maybe we just wanted to avoid code duplication?
  • So its [[HomeObject]] is rabbit, as it was created in rabbit. There’s no way to change [[HomeObject]].
  • The code of tree.sayHi() has super.sayHi() inside. It goes up from rabbit and takes the method from animal.

Methods, not function properties

[[HomeObject]] is defined for methods both in classes and in plain objects. But for objects, methods must be specified exactly as method(), not as "method: function()".

The difference may be non-essential for us, but it’s important for JavaScript.

In the example below a non-method syntax is used for comparison. [[HomeObject]] property is not set and the inheritance doesn’t work:

let animal = {
  eat: function() { // should be the short syntax: eat() {...}
    // ...
  }
};

let rabbit = {
  __proto__: animal,
  eat: function() {
    super.eat();
  }
};

rabbit.eat();  // Error calling super (because there's no [[HomeObject]])

Summary

  1. To extend a class: class Child extends Parent:
    • That means Child.prototype.__proto__ will be Parent.prototype, so methods are inherited.
  2. When overriding a constructor:
    • We must call parent constructor as super() in Child constructor before using this.
  3. When overriding another method:
    • We can use super.method() in a Child method to call Parent method.
  4. Internals:
    • Methods remember their class/object in the internal [[HomeObject]] property. That’s how super resolves parent methods.
    • So it’s not safe to copy a method with super from one object to another.

Also:

  • Arrow functions don’t have own this or super, so they transparently fit into the surrounding context.

Görevler

önem: 5

Aşağıda Animal’ıdan miras alan bir Rabbit sınıfı bulunmakta.

Aşağıda bunu yapmaya çalıştık fakat başarılı olamadık. Problemi bulabilir misiniz?

class Animal {

  constructor(name) {
    this.name = name;
  }

}

class Rabbit extends Animal {
  constructor(name) {
    this.name = name;
    this.created = Date.now();
  }
}

let rabbit = new Rabbit("White Rabbit"); // Error: this is not defined
alert(rabbit.name);

Çünkü Rabbit sınıfındaki yapıcı super’i yani ebeveny sınıfını çağırmalıdır. Düzeltilmiş kodu aşağıda görebilirsiniz:

class Animal {

  constructor(name) {
    this.name = name;
  }

}

class Rabbit extends Animal {
  constructor(name) {
    super(name);
    this.created = Date.now();
  }
}

let rabbit = new Rabbit("White Rabbit"); // ok now
alert(rabbit.name); // White Rabbit
önem: 5

Clock sınıfımız şu anda saniyede bir zamanı ekrana basmaktadır.

ExtendedClock adında Clock’tan türeyen yeni bir sınıf yazın ve buna precision – her saniye atışında kaç ms geçtiğini ekrana bassın. Normalde bu 1000 ms ( 1 sn ) olmalıdır.

  • Kodlarınız extended-clock.js içerisinde olmalıdır.
  • Orjinal clock.js üzerinde bir değişiklik yapmayın. Bundan türetin.

Görevler için korunaklı alan aç.

class ExtendedClock extends Clock {
  constructor(options) {
    super(options);
    let { precision=1000 } = options;
    this.precision = precision;
  }

  start() {
    this.render();
    this.timer = setInterval(() => this.render(), this.precision);
  }
};

Çözümü korunaklı alanda aç.

önem: 5

Bildiğiniz gibi objeler Object.prototype’tan kalıtım alır ve “generic” obje metodlarına bu şekilde erişir.

Aşağıda gösterildiği gibi:

class Rabbit {
  constructor(name) {
    this.name = name;
  }
}

let rabbit = new Rabbit("Rab");

// hasOwnProperty method is from Object.prototype
// rabbit.__proto__ === Object.prototype
alert( rabbit.hasOwnProperty('name') ); // true

Pek, "class Rabbit extends Object" ile "class Rabbit" aynımıdır, öyleyse neden?

Aşağıdaki kod çalışır mı?

class Rabbit extends Object {
  constructor(name) {
    this.name = name;
  }
}

let rabbit = new Rabbit("Rab");

alert( rabbit.hasOwnProperty('name') ); // true

Eğer çalışmaz ise çalışır hale getiriniz.

Cevap iki parçadan oluşmaktadır

Birinci bölüm, kolay olan kalıtım yapan sınıf yapıcı metodda super()'i çağırmalıdır. Diğer türlü "this" “tanımsız” olacaktır.

Çözümü şu şekildedir:

class Rabbit extends Object {
  constructor(name) {
    super(); // kalıtım yapıldığında üst sınıf çağırılmalıdır.
    this.name = name;
  }
}

let rabbit = new Rabbit("Rab");

alert( rabbit.hasOwnProperty('name') ); // true

Fakat henüz bitmedi.

Bu problem düzeltildikten sonra bile, "class Rabbit extends Object" ile class Rabbit arasında önemli bir fark vardır.

Bildiğiniz gibi “extends” yazımı iki prototip kurar:

  1. Yapıcı fonksiyonların "prototype" ları arasında ( metodlar için )
  2. Yapıcı fonksiyonların kendileri arasında ( statik metodlar için )

Bizim durumumuzda class Rabbit extends Object:

class Rabbit extends Object {}

alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
alert( Rabbit.__proto__ === Object ); // (2) true

anlamına gelir.

Object’in statik metodlarına Rabbit ile şu şekilde erişebiliriz:

class Rabbit extends Object {}

// normlade Object.getOwnPropertyNames'i çağırırız.
alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // a,b

Eğer extends kullanılmaz ise class Rabbit ikinci referansı alamaz.

Aşağıdaki ile karşılaştırabilirsiniz:

class Rabbit {}

alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
alert( Rabbit.__proto__ === Object ); // (2) false (!)

// hata, Rabbit diye bir fonksiyon bulunmamaktadır.
alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // Hata

Basit class Rabbit için Rabbit fonksiyonu aynı prototipe sahiptir.

class Rabbit {}

// (2) yerine kullanılır. Rabbit için doğrudur. (diğer fonksiyonlar için de)
alert( Rabbit.__proto__ === Function.prototype );

Bu arada Function.prototype’ın “generic” fonksiyonları bulunmaktadır. Bunlar, call, bind vs gibi metodlardır. Her iki durumda da bunlar mevcuttur çünkü Object yapısında varsayılan olarak bulunmaktadır. Object.__proto__ === Function.prototype

Son tahlilde görüntü şu şekildedir:

Özetlersek:

class Rabbit class Rabbit extends Object
yapıcı metodda super() çağırılmalıdır.
Rabbit.__proto__ === Function.prototype Rabbit.__proto__ === Object
Eğitim haritası