Słowem wstępu

Celem artykułu jest omówienie różnic pomiędzy include a extend w Rubym.

Zanim jednak do tego przejdziemy, warto przypomnieć sobie kilka podstawowych zasad związanych z definiowaniem i wywoływaniem metod.

Kilka słów o wywoływaniu metod

Gdzie właściwie siedzą metody?

Metody siedzą w klasie obiektu, gdzie nazywane są metodami instancyjnymi. Właśnie dzięki temu, obiekty (instancje) danej klasy mają dostęp do wspólnego zbioru metod.

Metody obiektu to tak naprawdę metody instancyjne jego klasy.

class MyClass
  def my_method
    # ...
  end
end
> a = MyClass.new
> a.methods == MyClass.instance_methods
=> true

Szukamy definicji

Co się zatem dzieje, gdy wywołujemy metodę na jakimś obiekcie?

W pierwszej kolejności sprawdzana jest klasa tego obiektu. Jeśli w niej znajduje się definicja szukanej metody - świetnie! Poszukiwania zakończone i wiadomo co wołać.

W przypadku braku definicji metody w klasie obiektu, przeszukiwane są jej klasy nadrzędne (zwane także przodkami). Łańcuch przodków każdego z obiektów możemy podejrzeć przy użyciu metody ancestors():

> a.class.ancestors
=> [MyClass, Object, Kernel, BasicObject]

Innym określeniem klasy nadrzędnej do obecnej jest termin superklasa. Dla MyClass superklasą jest Object:

> MyClass.superclass
=> Object

Łańcuch przodków jest przeszukiwany na obecność metody do znalezienia jej najbliższej definicji. Jeśli metody znaleźć się nie uda, zwracany jest wyjątek NoMethodError.

Co z wywoływaniem metod na klasie - “metodami klasowymi”?

Tak naprawdę nie ma czegoś takiego, jak metody klasowe - Ruby obsługuje tylko metody instancyjne!

W tym miejscu warto przypomnieć sobie o jednej z fundamentalnych zasad: w Rubym wszystko jest obiektem! Dotyczy to także klas - one też nie są niczym innym niż instancjami klasy Class.

class MyClass; end

> MyClass.class
=> Class

Pod pojęciem “metod klasowych” muszą się więc kryć znane nam już metody instancyjne, które muszą być zdefiniowane w klasie danego obiektu.

Jednym ze sposobów na dodanie metody klasowej jest więc zdefiniowanie jej w klasie Class:

class Class
  def my_class_method
    'this is an instance method of Class'
  end
end

> MyClass.my_class_method
=> "this is an instance method of Class"

Metoda klasowa jak się patrzy!

Oczywiście w takim przypadku jest ona dostępna dla wszystkich klas, bo każda z nich jest obiektem klasy Class:

class MyDifferentClass; end

> MyDifferentClass.my_class_method
=> "this is an instance method of Class"

Jak to się zatem dzieje, że definiując metodę klasową w sposób “klasyczny” nie jest ona po prostu wrzucana do Class?

class MyClass
  def self.class_method
    'this is a class method of MyClass'
  end
end

> Class.instance_methods.include?(:class_method)
=> false

W Rubym każdy obiekt ma swoją klasę _singletonową_ (zwaną też klasą anonimową lub wirtualną).

To właśnie w tej klasie singletonowej ląduje definicja “metody klasowej” - oczywiście już jako jej metoda instancyjna 🙂

>MyClass.singleton_class
=> #<Class:MyClass>

>MyClass.singleton_class.instance_methods.inlcude?(:class_method)
=> true

Metody zdefiniowane w klasie singletonowej można też podejrzeć w ten sposób:

> MyClass.singleton_methods
=> [:class_method]

Uzbrojeni w wiedzę dotyczącą definiowania i wywoływania metod możemy przejść do omawiania różnic między dziedziczeniem a modularnością.

Moduły a sprawa Klasowa

Modularność kontra dziedziczenie

Rozszerzać możliwości klasy możemy na dwa sposoby:

  • poprzez modularność,
  • poprzez dziedziczenie.

W idealnej sytuacji, napisane przez nas obiekty mają wysoką spójność (czyli jedną odpowiedzialność - Single Responsibly Objects) i mało zależności.

Modularność polega właśnie na rozbiciu problemu na osobne moduły - każdy z nich obarczony jedną odpowiedzialnością - które następnie można jasno dodawać do klasy według potrzeb.

Dziedziczenie często prowadzi do obiektów posiadających wiele różnych “odpowiedzialności”, które zostały hurtem otrzymane od obiektów-rodziców. Mniejsza jasność, większa złożoność, trudniejszy w ogarnięciu kod, kręcenie głowami, płacz, zgrzytanie zębów.

Modularność oczywiście również ma swoje wady: większą ilość kodu i więcej roboty przy dołączaniu wielu modułów. Jeśli okaże się, że podobny efekt uzyskalibyśmy dziedzicząc, a zysk z modularności jest mniejszy niż potrzebna jej nadwyżka pracy - używajmy dziedziczenia.

Innym przypadkiem, w którym użycie dziedziczenia jest sensowne, to sytuacja, kiedy mamy wiele obiektów należących do jednej “rodziny” i dziedziczących po wspólnym przodku. Przodek ten (najczęściej nazwiemy go BaseCośTam) zawiera wtedy metody odpowiedzialne za podstawową funkcjonalność, a potomkowie z nich korzystają i dodają do nich coś w razie potrzeby (a zdarza się, że nie dodają nic, i cała klasa potomka to tylko samo dziedziczenie!).

Jak modułować kod?

Za dołączanie modułów do klas w Rubym odpowiadają trzy słowa kluczowe:

  • include - zawieranie
  • prepend - poprzedzanie
  • extend - rozszerzanie

Zarówno include jak i prepend dodają metody instancyjne, extend - metody klasowe. Ale po kolei.

Include

Słowo kluczowe include pozwala na zawarcie w klasie określonego modułu i sprawia, że metody tego modułu będą traktowane jak metody instancyjne klasy - czyli będą dostępne dla jej instancji.

Moduł zawarty w klasie przy użyciu include wskoczy na drugie po niej miejsce w łańcuchu przodków tej klasy.

Modułów możemy w ten sposób dodać ile tylko nam się podoba, warto jednak pamiętać, że ostatnio dodany będzie brany jako pod uwagę jako pierwszy.

Krótki przykład:

module Morning
  def greeting
    'Good morning!'
  end
end

module Evening
  def greeting
    'Good evening!'
  end
end

class MyClass
  include Morning
  include Evening
end
> MyClass.ancestors
=> [MyClass, Evening, Morning, Object, Kernel, BasicObject]
> MyClass.new.greeting
=> "Good evening!"

Prepend

Rzadziej używany odpowiednik include, który dla odmiany wrzuca moduły na początek łańcucha przodków (czyli jeszcze przed samą klasą):

class MyClass
  prepend Morning
  prepend Evening
end
> MyClass.ancestors
=> [Evening, Morning, MyClass, Object, Kernel, BasicObject]
> MyClass.new.greeting
=> "Good evening!"

Częstszym przykładem użycia prepend jest rozbudowanie metod otrzymanych od przodków przy użyciu super:

module Descriptor
  def get_to_work
    puts "Time to work..."
    puts "-----"
    super
    puts "-----"
    puts "Work is done!"
  end
end

class Worker
  prepend Descriptor

  def get_to_work
    puts "Calculatig 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8..."
    puts "Result is: #{(1..8).inject(:*)}"
    return 1
  end
end
> Worker.new.get_to_work
Time to work...
-----
Calculatig 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8...
Result is: 40320
-----
Work is done!

Extend

Tak jak wspomnieliśmy, Ruby wspiera jedynie metody instancyjne. Wszystkie metody klasowe są metodami instancyjnymi klasy singletonowej, którą posiada każdy obiekt.

Okazuje się, że taka klasa singletonowa ma swój własny łańcuch przodków. Właśnie do tego łańcucha dodany zostaje moduł, którym extendujemy daną klasę.

Zobaczmy to na prostym przykładzie:

module Night
  def greeting
    'Good night.'
  end
end

class MyClass
  extend Night
end

Metody z modułu Night nie zostają dodane bezpośrednio do klasy singletonowej klasy MyClass:

> MyClass.singleton_class.instance_methods(false)
=> []

ale dzięki obecności Night w łańcuchu przodków klasy singletonowej:

> MyClass.singleton_class.ancestors
=> [#<Class:MyClass>, Night, #<Class:Object>, ...]

metody zdefiniowane w Night są widoczne jako metody singletonowe i możemy je wywołać:

> MyClass.singleton_methods
=> [:greeting]

> MyClass.greeting
=> "Good night."

Metody klasowe przez include

Railsy umożliwiają dodanie także metod klasowych przy użyciu tylko include‘a. Służy do tego metoda included.

included przyjmuje argument określający obiekt, do którego dany moduł jest załączany. Możemy więc w jej ciele rozszerzyć ten moduł o metody klasowe:

def self.included(base)
  base.extend(ClassMethods)
end

I na przykładzie:

module ChitChat
  def greeting
    'Hello!'
  end

  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def salutation
      'Cheers :)'
    end
  end
end

class Person
  include ChitChat
end
> Person.new.greeting
=> "Hello!"
> Person.salutation
=> "Cheers :)"

# a także:
> Person.greeting
NoMethodError: undefined method `greeting' for Person:Class
> Person.new.salutation
NoMethodError: undefined method `salutation' for #<Person:0x00007fc09f06fba0>

tl;dr, czyli podsumowanie

  • wszystko jest obiektem!
  • obiekty to zbiór zmiennych instancyjnych i link do klasy,
  • metody obiektu znajdują się w jego klasie, gdzie nazywamy je metodami instancyjnymi,
  • klasa to tylko obiekt z listą metod instancyjnych i linkiem do jej klasy,
  • wywołanie metody szuka jej definicji w klasie obiektu, na którym jest wołana, a następnie w jej “przodkach” (ancestors()),
  • zawieranie modułu (include) w klasie umieszcza go w łańcuchu przodków tuż za klasą,
  • dodanie do klasy modułu przy użyciu prepend sprawi, że moduł będzie w łańcuchu przodków przed samą klasą,
  • metody klasowe nie są zdefiniowane w samej klasie, tylko w nadrzędnej jej klasie anonimowej singleton - tak działa rozszerzanie klasy modułami przez extend.

Za współpracę przy artykule bardzo dziękuję Alicji Cyganiewicz! 🙂