Ostatnio w pracy coraz częściej wykorzystuję Spocka do pisania testów przez co siłą rzeczy mam kontakt z Groovy. Jest to obiektowy język programowania z rodziny JVM. Przez zastosowanie w nim dynamicznego jak i statycznego typowania może być on wykorzystywany do tworzenia skryptów. Dzięki wielu użytecznym funkcjom Groovy znacznie usprawnia pracę programisty. Właśnie jedną z nich są tytułowe Closures, które chciałbym pokrótce przedstawić w tym artykule.

Krótka definicja Closures

Closures, czyli domknięcie, to w kilku słowach po prostu funkcja ze stanem. Co to właściwie oznacza? Że posiada ona dostęp do zmiennych spoza swojego ciała - globalnych lub należących do funkcji nadrzędnej. Można powiedzieć, że najczęściej jest to funkcja zawarta w innej funkcji. Sprawdźmy to na przykładzie zdefiniowanym w świecie TypeScript.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createPerson(personFirstname: string,
                      personLastname: string): (greeting: string) => void {
  let firstname = personFirstname;
  let lastname = personLastname;

  function greet(greeting: string) {
    console.log(`${greeting}, I am ${firstname} ${lastname}!`);
  }

  return greet;
}

let greeting = createPerson('Albert', 'Krasiński');
greeting('Hello'); // Hello, I am Albert Krasiński!
greeting('Good morning'); // Good morning, I am Albert Krasiński!

Tworzymy funkcję createPerson, której przekazujemy dwie zmienne reprezentujące imię i nazwisko danej osoby. Następnie definiujemy w jej wnętrzu kolejną funkcję greet, która korzysta z przygotowanych wcześniej firstname oraz lastname. Przy okazji sama przyjmuje parametr podany przez użytkownika będący przywitaniem. W ten sposób właśnie utworzyliśmy domknięcie. Wynika to z faktu, że funkcja greet ma dostęp do zmiennych funkcji createPerson. Aby to sprawdzić przypiszmy do zmiennej greeting zwracaną funkcję wewnętrzną i wywołajmy ją dwukrotnie. W konsoli powinniśmy zobaczyć komunikaty zawierające przekazane przez nas imię i nazwisko. Potwierdza to kwestię, że greet jest skojarzona ze środowiskiem funkcji createPerson. Skoro już wiemy czym są domknięcia to czas przejść do praktyki i zobaczyć jak ta konstrukcja wygląda w Groovy.

Podstawowa budowa Closures w Groovy

Zacznijmy od bardzo prostego przypadku domknięcia, w którym zwiększymy dany licznik o jeden. Tworzymy zmienną counter, następnie przypisujemy Closure do zmiennej incrementCounter, którą wywołujemy. Na koniec sprawdzamy czy wartość licznika faktycznie powiększyła się o zadaną wartość.

1
2
3
4
def counter = 23
def incrementCounter = { counter++ }
incrementCounter()
assert counter == 24

Ten zapis jest naprawdę kompaktowy, ale sporo przed nami ukrywa. Nie musieliśmy podawać informacji o tym, że nie przekazujemy żadnego parametru. Dodatkowo mogliśmy również pominąć strzałkę znaną z zapisu lambda ->. Gdybyśmy chcieli to możemy ją oczywiście zapisać explicite w następujący sposób: { -> counter++ }.

Co w przypadku, gdybyśmy jednak chcieli przekazać jakiś parametr? Groovy domyślnie za nas doda taką możliwość. Wystarczy w ciele domknięcia wykorzystać nazwę zmiennej it, której nie ma naocznie zdefiniowanej.

1
2
3
def returnSentence = { "Sentence: ${it}" }
def sentence = returnSentence('Hello!')
assert sentence == "Sentence: Hello!"

Przekazaliśmy literał Hello!, który został przypisany do parametru it. W ten sposób mogliśmy użyć go do stworzenia napisu reprezentującego zdanie. Powyższy zapis jest po prostu równoznaczny z { it -> "Sentence: ${it}" }. Jeśli chcielibyśmy zmienić nazwę tego parametru to również mamy taką możliwość i jest to bardzo proste - { words -> "Sentence: ${words}" }. Wystarczy zmienić domyślną nazwę it na taką, która nam odpowiada.

Różnica pomiędzy Closure a lambdą

Jeśli przyjrzelibyśmy się bliżej zmiennej returnSentence z przykładu wyżej to dowiedzielibyśmy się, że jest ona typu groovy.lang.Closure. Właśnie z tego powodu domknięcie znacznie odróżnia się od lambdy znanej z Javy 8. Groovy dzięki Closure pozwala nam na użycie koncepcji delegacji, której nie spotkamy u starszego brata z rodziny JVM. Sprawdźmy zatem jaką daje to nam przewagę.

Kto jest właścicielem domknięcia w Groovy?

Na początku warto przyjrzeć się słowu kluczowemu this użytemu w domknięciach. Rozróżniane są 3 podejścia:

  • this - odnosi się do klasy, która zawiera Closure
  • owner - wskazuje na obiekt posiadający dane domknięcie (może to być klasa lub inne domknięcie)
  • delegate - określa podmiot trzeci, który możemy wybrać ręcznie

Brzmi to może trochę enigmatycznie, ale mam nadzieję, że wyjaśni się co nie co za chwilę. Nie zwlekając przejdźmy do najprostszego przypadku z listy, czyli do this.

Słowo kluczowe this w domknięciach

Ten przypadek wydaje się dosyć intuicyjny z racji pisania kodu w Javie. this po prostu odwołuje się do obiektu, w którym aktualnie jesteśmy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ClassWithThis {
  void checkGetThisObjectMethod() {
    def thisReference = { getThisObject() }          
    assert thisReference() == this
  }

  void checkThisKeyword() {
    def thisReference = { this }
    assert thisReference() == this 
  }
}

def object = new ClassWithThis()
object.checkGetThisObjectMethod()
object.checkThisKeyword()

Asercje przeszły co oznacza, że mieliśmy rację. Słowo kluczowe this w przypadku domknięć odwołuje się do obiektu danej klasy, który je zawiera. Warto zwrócić uwagę, że metoda getThisObject, dostępna w klasie groovy.lang.Closure, również zwraca nam referencję do obiektu. Porównując ją do this uzyskamy jak najbardziej wartość true.

Ta sama zasada ma zastosowanie w przypadku klas wewnętrznych oraz zagnieżdżonych domknięć. W przypadku umiejscowienia Closure w klasie będącej w innej klasie to this będzie i tak odwoływało się do tej, która ją zawiera. W przypadku zagnieżdżenia ma miejsce ta sama zasada. Idąc w górę zawierania od interesującego nas domknięcia, gdy natrafimy na pierwszą klasę to ona będzie reprezentowała słowo kluczowe this.

1
2
3
4
5
6
7
8
9
10
11
12
13
class ClassWithInnerClass {
  class InnerClass {
    Closure innerClosure = { this }
  }

  void checkThisKeywordForInnerClass() {
    InnerClass inner = new InnerClass()
    assert inner.innerClosure() == inner
  }
}

def object = new ClassWithInnerClass()
object.checkThisKeywordForInnerClass()
1
2
3
4
5
6
7
8
9
10
11
12
class ClassWithInnerClosure {
  void checkThisKeywordForInnerClosure() {
    def innerClosure = { 
      def closure = { this }
      closure()
    }
    assert innerClosure() == this
  }
}

def object = new ClassWithInnerClosure()
object.checkThisKeywordForInnerClosure()

Właściciel Closure

Różnica pomiędzy this a owner jest naprawdę subtelna. W większości przypadków będą zachowywały się podobnie, jednak słowo kluczowe owner jest bardziej dokładne, ponieważ będzie zwracało uwagę nie tylko na klasy zawierające domknięcie, ale i również na same domknięcia.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ClassWithOwner {
  void checkGetOwnerMethod() {
    def ownerReference = { getOwner() }
    assert ownerReference() == this
  }

  void checkOwnerKeyword() {
    def ownerReference = { owner }
    assert ownerReference() == this 
  }
}

def object = new ClassWithOwner()
object.checkGetOwnerMethod()
object.checkOwnerKeyword()
1
2
3
4
5
6
7
8
9
10
11
12
13
class ClassWithInnerClass {
  class InnerClass {
    Closure innerClosure = { owner }
  }

  void checkOwnerKeywordForInnerClass() {
    InnerClass inner = new InnerClass()
    assert inner.innerClosure() == inner
  }
}

def object = new ClassWithInnerClass()
object.checkOwnerKeywordForInnerClass()
1
2
3
4
5
6
7
8
9
10
11
12
class ClassWithInnerClosure {
  void checkOwnerKeywordForInnerClosure() {
    def innerClosure = { 
      def closure = { owner }
      closure()
    }
    assert innerClosure() == innerClosure
  }
}

def object = new ClassWithInnerClosure()
object.checkOwnerKeywordForInnerClosure()

Jak widzisz dwa pierwsze przypadki mają identyczne działanie jak w przypadku słowa kluczowego this. Sytuacja się zmienia, gdy jedno domknięcie jest zagnieżdżone w drugim. W tym przypadku owner zwraca referencję do zewnętrznej instancji Closure.

Delegacja domknięcia

Ten przypadek jest najbardziej skomplikowany. Dzięki niemu możemy manipulować przy wywołaniach Closure poprzez przypisanie do niej wybranego przez nas dowolnego obiektu. Referencję delegacji uzyskujemy używając słowo kluczowe delegate albo metodę getDelegate. Domyślnie będą nam one zwracały tą samą referencję co owner.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ClassWithDelegate {
  void checkDefaultDelegation() {
    def closureGetDelegate = { getDelegate() }
    def closureDelegate = { delegate }
    assert closureGetDelegate() == closureDelegate()
    assert closureGetDelegate() == this
      
    def innerClosureWithDelegate = {
      { -> delegate }.call()
    }
    assert innerClosureWithDelegate() == innerClosureWithDelegate
  }
}

def object = new ClassWithDelegate()
object.checkDefaultDelegation()

Sprawdźmy jak możemy zmienić to przypisanie. Zdefiniujmy dwie klasy, które nic nie mają ze sobą wspólnego poza posiadaniem pola o tej samej nazwie oraz typie.

1
2
3
4
5
6
7
8
9
class Kid {
  double heightInMeters
}
class Building {
  double heightInMeters
}

def kid = new Kid(heightInMeters: 1.20)
def building = new Building(heightInMeters: 35.24)

Teraz zdefiniujmy domknięcie zmieniające metry na centymetry. Jednak jako odwołanie do pola użyjemy słowa kluczowego delegate.

1
def convertToCentimeters = { delegate.heightInMeters * 100 }

Teraz jeśli do zmiennej delegate, dla przed chwilą stworzonego Closure, przypiszemy dany obiekt to powinniśmy otrzymać prawidłowe wyniki w centymetrach. W ten sposób oddelegowaliśmy właściciela danego domknięcia przez co Closure będzie w swoim ciele odwoływał się do elementu przez nas wskazanego.

1
2
3
4
convertToCentimeters.delegate = kid
assert convertToCentimeters() == 120
convertToCentimeters.delegate = building
assert convertToCentimeters() == 3524

Oczywiście nie musimy podawać explicite delegate w ciele domknięcia. Skoro zmieniła ona właściciela to Groovy się tego domyśli i załatwi sprawę za nas. Wtedy zapis sprowadziłby się do zdefiniowania zwykłej Closure w następujący sposób: def convertToCentimeters = { heightInMeters * 100 }.

Możliwe strategie delegowania

Twórcy Groovy pozwolili nam zmieniać strategie delegowania domknięć. To co widzieliśmy powyżej to domyślne zachowanie, które możemy dostosować zgodnie z naszymi potrzebami. Poniżej znajduje się zestawienie możliwych opcji do wyboru:

  • Closure.OWNER_FIRST (domyślnie) - jeśli dane pole lub metoda istnieje u właściciela domknięcia to właśnie one zostaną użyte, w innym przypadku zostanie zastosowany mechanizm delegowania
  • Closure.DELEGATE_FIRST - odwraca logikę Closure.OWNER_FIRST: najpierw używana jest delegacja, a później właściwości właściciela
  • Closure.OWNER_ONLY - bierze pod uwagę tylko pola i metody właściciela, ignoruje wykorzystanie mechanizmy delegacji
  • Closure.DELEGATE_ONLY - przeciwieństwo Closure.OWNER_ONLY: skupia się tylko na delegowaniu, a ignoruje właściciela
  • Closure.TO_SELF - opcja dla bardziej zaawansowanych programistów do implementowania własnego sposobu delegowania

Sprawdźmy teraz jak zachowują się poszczególne strategie na przykładach. Pominiemy jedynie Closure.TO_SELF, ponieważ w tym artykule zahaczamy tylko o podstawowe mechanizmy domknięć w Groovy.

Closure.OWNER_FIRST

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Kid {
  double heightInMeters
  String name

  def sayHi = { "My name is ${name}" }
  String greet() {
    sayHi()
  }

  def convertHeightToCentimeters = { heightInMeters * 100 }
  int getHeightInCentimeters() {
    convertHeightToCentimeters()
  }

  def convertFoundationDepthToCentimeters = { foundationDepthInMeters * 100 }
  int getFoundationDepthInCentimeters() {
    convertFoundationDepthToCentimeters()
  }
}
class Building {
  double heightInMeters
  double foundationDepthInMeters
}

def kid = new Kid(heightInMeters: 1.20, name: "Bob")
def building = new Building(heightInMeters: 35.24, foundationDepthInMeters: 1.40)

kid.convertHeightToCentimeters.resolveStrategy = Closure.OWNER_FIRST // default (not have to be set explicite)

assert kid.getHeightInCentimeters() == 120
kid.convertHeightToCentimeters.delegate = building
assert kid.getHeightInCentimeters() == 120

kid.convertFoundationDepthToCentimeters.resolveStrategy = Closure.OWNER_FIRST // default (not have to be set explicite)

try {
  kid.getFoundationDepthInCentimeters()
  assert false
} catch (MissingPropertyException ex) {
  // No such property: foundationDepthInMeters for class: Kid
}
kid.convertFoundationDepthToCentimeters.delegate = building
assert kid.getFoundationDepthInCentimeters() == 140

W pierwszym przypadku pomimo zmiany delegacji to i tak została wywołana metoda z właściwościami dla klasy Kid. Jest to jak najbardziej oczekiwane działanie domyślnej strategii delegacji. W przypadku jednak, gdy mamy zdefiniowaną metodę w klasie Kid, która wykorzystuje nieznane jej pole to zmiana delegacji zadziała.

Closure.DELEGATE_FIRST

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//...

kid.sayHi.resolveStrategy = Closure.DELEGATE_FIRST

assert kid.greet() == "My name is Bob"
kid.sayHi.delegate = building
assert kid.greet() == "My name is Bob"

kid.convertHeightToCentimeters.resolveStrategy = Closure.OWNER_FIRST

assert kid.getHeightInCentimeters() == 120
kid.convertHeightToCentimeters.delegate = building
assert kid.getHeightInCentimeters() == 120

kid.convertHeightToCentimeters.resolveStrategy = Closure.DELEGATE_FIRST

assert kid.getHeightInCentimeters() == 3524

Już na samym początku ustawiamy strategię, aby na pierwszym miejscu stawiać delegację. Jednak w metodzie sayHi wykorzystywane jest pole name, które w klasie Building nie ma. Z tego powodu wywołanie kid.greet() zwraca dwa razy ten sam wynik pomimo zmiany referencji do delegate. Natomiast dla drugiego przykładu wystarczy wybrać Closure.DELEGATE_FIRST, aby metoda getHeightInCentimeters priorytetyzowała delegację.

Closure.OWNER_ONLY

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//...

kid.convertFoundationDepthToCentimeters.resolveStrategy = Closure.OWNER_ONLY

try {
  kid.getFoundationDepthInCentimeters()
  assert false
} catch (MissingPropertyException ex) {
  // No such property: foundationDepthInMeters for class: Kid
}
kid.convertFoundationDepthToCentimeters.delegate = building
try {
  kid.getFoundationDepthInCentimeters()
  assert false
} catch (MissingPropertyException ex) {
  // No such property: foundationDepthInMeters for class: Kid
}

Nie ważne co by się działo to i tak nasze domknięcie będzie tylko patrzyło na swojego właściciela. Nawet w sytuacji, gdy dana klasa nie będzie miała pola mogącego być obsłużonym.

Closure.DELEGATE_ONLY

1
2
3
4
5
6
7
8
9
10
11
12
//...

kid.sayHi.resolveStrategy = Closure.DELEGATE_ONLY

assert kid.greet() == "My name is Bob"
kid.sayHi.delegate = building
try {
  kid.greet()
  assert false
} catch (MissingPropertyException ex) {
  // No such property: name for class: Building
}

Początkowo delegate wskazuje na owner, więc pierwsze wywołanie jak najbardziej przejdzie bez rzucenia wyjątku. Natomiast jeśli przy tej strategii wskażemy na inny obiekt i on nie będzie posiadał interesującej nas właściwości to pomimo to i tak będziemy dla niego wywoływać daną metodę.

Na co uważać przy korzystaniu z GString

Korzystając z GString w domknięciu zawartym w klasie i zmieniając wartość pola obiektu wykorzystywanego w GString, uzyskiwany literał przy każdym wywołaniu będzie ulegał zmianie. Działa, więc to tak jakbyśmy się tego spodziewali.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Kid {
  String name

  def sayHi = { "My name is ${name}" }
  String greet() {
    sayHi()
  }
}

def kid = new Kid(name: "Bob")

assert kid.sayHi() == "My name is Bob"
kid.name = "John"
assert kid.sayHi() == "My name is John"

Natomiast sytuacja staje się zgoła odmienna, gdy wyjdziemy poza ramy klasy. GString nie będzie już tak łatwo ulegał zmianie. Pierwsza asercja przejdzie jednak problem pojawi się później. Gdy zmiennej name przypiszemy inną wartość to GString nie ulegnie zmianie. Kolejna asercja po prostu rzuci nam błąd.

1
2
3
4
5
6
def name = "Bob"
def sayHi = "My name is ${name}"
assert sayHi == "My name is Bob"

name = "John"
assert sayHi == "My name is John" //fails!

Dzieje się tak, ponieważ GString zmienia reprezentację swojej metody toString tylko wtedy, gdy wartości w nim przetrzymywane są mutowalne. W przypadku, gdy zmieni się ich referencja to ta zmiana nie będzie miała odzwierciedlenia w otrzymanym literale. Z tego powodu przypadek z klasą jak najbardziej zadziałał, a ze zwykłą zmienną już nie.

Co możemy zrobić, aby to zmienić? Odpowiedzią się oczywiście domknięcia, które możemy zdefiniować w GString. Wystarczy w naszym przypadku zmienić wartość w GString w nawiasach klamrowych na -> name. W ten sposób explicite deklarujemy pustą listę argumentów domknięcia. Ta konstrukcja pozwala nam na wykorzystywanie zmieniających się referencji dla wybranych zmiennych. Nie musimy w takim przypadku polegać na mutowalnych obiektach czy sztucznych wrapperach.

1
2
3
4
5
6
def name = "Bob"
def sayHi = "My name is ${-> name}"
assert sayHi == "My name is Bob"

name = "John"
assert sayHi == "My name is John"

Sposoby zwijania funkcji

Ostatnią rzeczą jaką chciałem przekazać jest możliwość zwijania domknięć. Ta funkcjonalność w Groovy nie odzwierciedla prawdziwej koncepcji stojącej za zwijaniem funkcji znanej z programowania funkcyjnego. Jej zadaniem jest po prostu zwrócenie nowej Closure, która akceptuje mniej parametrów. Do dyspozycji mamy 3 podejścia: curry, rcurry i ncurry.

Pierwsze z nich to lewe zwinięcie, czyli podając wybraną wartość do tej metody zastąpimy lewy parametr danego domknięcia. W przypadku rcurry sytuacja jest identyczna tylko skupimy się na prawym parametrze. Natomiast przy ncurry jesteśmy w stanie wybrać parametr, który chcemy zastąpić wskazując jego indeks. Dodatkowo możemy podać więcej wartości do tej metody, dzięki czemu przypiszemy wartości kolejnym argumentom domknięcia.

1
2
3
4
5
6
7
8
9
10
11
def example = { String name, int amount, String thingInPlural -> "${name} has ${amount} ${thingInPlural}" }
assert example('Mary', 3, 'dogs') == "Mary has 3 dogs"

def lcurryExample = example.curry("Bob")
assert lcurryExample(4, 'parrots') == "Bob has 4 parrots"

def rcurryExample = example.rcurry("fish")
assert rcurryExample('John', 10) == "John has 10 fish"

def ncurryExample = example.ncurry(1, 6, "hamsters")
assert ncurryExample('Robert') == "Robert has 6 hamsters"

Podsumowanie

Mam nadzieję, że temat domknięć w Groovy jest dla Ciebie jasny po tym artykule. Jeśli chcesz jednak dowiedzieć się czegoś więcej to zapraszam na stronę z oficjalną dokumentacją Groovy, z której ja czerpałem wiedzę. Jest tam wszystko jasno i przejście wytłumaczone.