Bloki, domknięcia i yield w Rubim

Posted by Jacek Galanciak on

W tej części tutoriala poruszymy bez wątpienia jeden z ważniejszych elementów składni Rubiego. Dzięki tej wiedzy z łatwością unikniemy powtórzeń kodu, a także będziemy mogli implementować własne iteratory, co uczynimy na końcu tutoriala.

begin i end

Klamra zbudowana z tych dwóch słów kluczowych służy do grupowania instrukcji. Stosuje się je do obsługi wyjątków oraz modyfikatorów instrukcji warunkowych.

Spójrzmy na działanie klamry begin - end na zasięg zmiennych:

begin
  a = 10
end
puts a

Program na wyjściu da nam 10. Zadeklarowanie zmiennej w takiej klamrze nie spowoduje , że będzie ona lokalna i istniała tylko w bloku begin - end.

Dla porównania, spójrzmy na poniższy przykład:

loop do
  a = 10
  break
end
puts a

W tym przypadku otrzymamy błąd: undefined local variable or method `a' for main:Object (NameError).

Błąd otrzymamy także w kodzie:

begin
  break
end

unexpected break (LocalJumpError)

Tak, jak pisałem w poście o pętlach, modyfikatory wyrażeń warunkowych i pętli można stosować do więcej niż jednej instrukcji - pod warunkiem, że się je odpowiednio zgromadzi:

a = b = 0
begin
  a += 1
  b += 0.1
end until a > 10

yield

Bardzo dziwne, ale użyteczne słowo. Będzie się dość często przewijać przez dalszą część tego tutoriala i przez cały czas trwania Waszej rubinowej kariery.

Przyjrzyjmy się przykładowi niżej:

def twice
  yield
  yield
end

# 1
twice do
  puts "Hello!"
end

# 2
twice { puts "Hello!" }

Instrukcja yield wykonuje blok, który został przekazany do metody. Blok przekazujemy poprzez do - end lub nawiasy klamrowe. Drugi przypadek stosuje się często do małych bloków. Jeżeli są dłuższe niż jedna instrukcja, a całość chcemy zmieścić w jednej linii kodu, instrukcje oddzielamy średnikami.

Wywołajmy twice bez żadnego bloku.

twice

Otrzymamy błąd in `twice': no block given (LocalJumpError). Wypadało by się przed czymś takim zabezpieczyć.

def twice
  if block_given?
    yield
    yield
  else
    puts "Dzisiaj mam wolne :)"
  end
end

twice do
  puts "Hello"
end

twice

Metoda block_given? pozwala na sprawdzenie, czy metodzie przekazano blok. Program w takiej wersji da na wyjściu:

Hello
Hello
Dzisiaj mam wolne :)

Przekazywanie obiektów do bloku

Nie raz już przekazywaliśmy obiekty do bloku, umieszczając go między znakami |. Teraz napiszemy własną metodę, która to zrealizuje:

def desc
  a = 1;
  10.times do
    a *= 0.5
    yield a if block_given?
  end
  return a
end

puts desc # 1
desc do |val| # 2
  print "Iteracja: #{(-Math.log(val)/Math.log(2)).to_i}: "
  puts val
end
0.0009765625
Iteracja: 1: 0.5
Iteracja: 2: 0.25
Iteracja: 3: 0.125
Iteracja: 4: 0.0625
Iteracja: 5: 0.03125
Iteracja: 6: 0.015625
Iteracja: 7: 0.0078125
Iteracja: 8: 0.00390625
Iteracja: 9: 0.001953125
Iteracja: 10: 0.0009765625

Metodę desc wywołaliśmy na dwa sposoby - z blokiem i bez niego.

proc i lambda

Mówi się, że wszystko w Rubim jest obiektem. Nie jest to do końca prawdą, gdyż blok nim nie jest. Z łatwością można go jednak skonwertować na obiekt klasy Proc.

Blok sam w sobie nie może istnieć. Zarówno pierwszy, jak i drugi przypadek zwróci błąd.

{ puts "Hello" } # 1

do # 2
  puts "Hello"
end

Z łatwością możemy jednak stworzyć lambdę, czyli blok kodu przechowany w obiekcie, by potem wywołać ją poprzez metodę call.

p1 = lambda do
  puts "Hello"
end

p1.class # => Proc

Można użyć także metody proc, która jest synonimem lambda, ale bardziej zaleca się stosowanie lambda.

Lambdy a kontekst bloku

Lambdy posiadają niezwykle ciekawą właściwość: pamiętają kontekst, w jakim zostały wywołane. W praktyce oznacza to, że pamiętają wartości swoich zmiennych i ponowne wywołaniebloku ich nie wyzeruje - wprost przeciwnie, lambda będzie kontynuować swoje działanie, korzystają z takich wartości swoich zmiennych, z jakimi je ostatnio porzuciła.

Napiszemy teraz program, który stworzy trzy “podprocesy” o takiej samej treści, które pokażą, że każdy z osobna będzie pamiętał swoje własne zmienne.

def proc1
  val = "default"
  lambda do
    puts "Poprzednia wartosc: #{val}. Podaj wartosc aktualna:"
    val = gets
  end
end

p1 = proc1
p2 = proc1
p3 = proc1
procs = [p1, p2, p3]

loop do
  3.times do |i|
    print "procs[#{i}]: "
    procs[i].call
  end
end

Warto zajrzeć do komentarzy wpisu o wejściu i wyjściu, które zainspirowały mnie do rozwinięcia tego właśnie akapitu.

Proc.new a lambdy

Okazuje się, że jest jeszcze trzeci sposób tworzenia obiektów Proc:

p1 = Proc.new { puts "Hello" }
p1.call

Jedną z różnic jest fakt, ze Proc.new nie sprawdza zgodności ilości argumentów, jaką przekazujemy do bloku.

Kolejna różnica przesądza o fakcie, że bardziej zaleca się używanie lambd.

Instrukcja return a obiekty Proc

Surowe obiekty Proc (ang. raw procs), czyli utworzone poprzez Proc.new, posiadają jedną niedogodność: użycie instrukcji return powoduje nie tyle wyjście z proca, co wyjście z całego bloku, w którym proc był wywołany. Może to powodować niespodziewane wyniki działania naszych programów, dlatego zaleca się używanie lambd, a nie surowych proców.

def proc1
  p = Proc.new { return -1 } 
  p.call
  puts "Nikt mnie nie widzi :-("
end

def proc2
  p = lambda { return -1 }
  puts "Blok zwraca #{p.call}"
end

Wywołany proc1 zwraca jedynie wartość, nie wypisze żadnego tekstu. Odmiennie działa proc2 - tutaj return powoduje, że sama labda zwraca wartość, do której można się odwołać w dalszej części bloku, w którym utworzono lambdę.

Przykłady

Wynajdziemy teraz na nowo koło i napiszemy własną wersję instrukcji until wykorzystując naszą wiedzę o yieldach.

def _until(cond)
  return if cond
  yield
  retry
end

a = 0
_until a == 5 do
  print a, " "
  a += 1
end

Wyjście: 0 1 2 3 4

Napiszmy teraz własną wersję metod all? (zwraca true, jeśli wszystkie elementy kolekcji powodują, że dany blok zwraca true) oraz any? (zwraca true, jeśli którykolwiek z elementów …) [polecam przejrzenie wpisu o iteratorach i implementację paru innych w ramach ćwiczenia]:

class Array
  def _all?
    self.each do |element|
      return false if not yield element
    end
    return true
  end

  def _any?
    self.each do |element|
      return true if yield element
    end
    return false
  end
end

a = [2, 4, 6]
a._all? { |e| e%2 == 0 } # => true
a._any? { |e| e > 4 } # =? true

(Tak naprawdę metody all? i any? należą do modułu Enumerable, ale tutaj chodzi o przykład, a nie o stuprocentową poprawność.)