Szalom!

Witam się po hebrajsku, bo jedna z Wróżek-korektorek stwierdziła, że “hej” jest pedalskie. Ale do rzeczy. W kolejnym odcinku traktującym o języku F# chciałem się skupić na jednej z postawowych koncepcji programowania funkcyjnego, a mianowicie:

Funkcje są wyrażeniami

Wyrażenia

Czym jest owo wyrażenie? Mówiąc w skrócie, jest to funkcja, która nie zapisuje żadnych zmiennych pośrednich i zwraca wartość. Najprościej będzie mi to wyjaśnić na przykładzie.

To nie jest wyrażenie:

public void ComputeHsvColor(int red, int green, int blue)
{
    var hue = ComputeHue(red, green, blue);
    var saturation = ComputeSaturation(red, green, blue);
    var value = ComputeValue(red, green, blue);
    
    this.HsvColor = String.Format("{0}{1}{2}", hue, saturation, value);
}

To za to jest wyrażeniem:

public string ComputeHsvColor(int red, int green, int blue)
{
    return String.Format("{0}{1}{2}",
        ComputeHue(red, green, blue),
        ComputeSaturation(red, green, blue),
        ComputeValue(red, green, blue));
}

Prawdziwy bałagan, prawda? Składnia nie powala, ale zajmiemy się nią później. W tej chwili ważne jest to, że uzyskaliśmy wyrażenie. Wyrażenie w C# najprościej rozpoznać po tym, że ciało funkcji zaczyna się od słowa kluczowego return.

Co nam to właściwie dało? Brak utrzymywania stanu na zewnątrz fukcji powoduje, że są one od siebie odizolowane. W konsekwencji funkcje można łatwo zamieniać między sobą i organizować w bardziej złożone fragmenty aplikacji. Jeżeli mieliście doświadczenie z testami jednostkowymi, to możecie dostrzec kolejny istotny benefit. Odizolowany kod jest bardzo prosty w przetestowaniu.

Ok, ok. Testy to jedno, a czytelność kodu to drugie. Zajmijmy się więc tym zagadnieniem. F# oferuje kilka ciekawych rozwiązań, dzięki którym łatwiej zrozumiecie działanie wyrażeń.

Operacje na wyrażeniach w F#

F# posiada szeroki wachlarz lukrów składniowych, ułatwiających pracę z wyrażeniami. Z punktu widzenia dzisiejszego wpisu, szczególnie przydatne są trzy konstrukcje:

  • dopasowywanie wzorców za pomocą match .. with,
  • operator forward pipe,
  • operator forward composition.

Dopasowywanie wzorców

Dopasowywanie wzorców (ang. pattern matching) w F# realizuje się za pomocą operatora match .. with. Jest to taki switch na sterydach. W głowach doświadczonych programistów języków obiektowych powinna zapalić się czerwona lampka. Switch jest przecież brzydkim zapachem w kodzie, a jego wystąpienie oznacza zwykle brak lub źle zastosowane wzorce projektowe. W F# jest jednak inaczej.

let someFunction someArgument someDiffrentArgument =
    match someArgument with
    | arg when arg < 0 -> computeAnotherPartOfCode()
    | arg when arg % 2 == 0 -> doSomething(arg / 2)
    | 7 -> doSomething(someDiffrentArgument)
    | arg -> doSomethingElse(arg)
    | _ -> launchRocket()

Istotny jest fakt, że każde z dopasowań musi zwracać ten sam typ danych. Jeżeli funkcja zawiera jedynie dopasowywanie wzorców, można zastosować jeszcze krótszą składnię. Pierwszy parametr przekazany do funkcji jest wtedy obiektem dopasowywania:

let someFunction someDiffrentArgument = function
    | arg when arg < 0 -> computeAnotherPartOfCode()
    | arg when arg % 2 = 0 -> doSomething(arg / 2)
    | 7 -> doSomething(someDiffrentArgument)
    | arg -> doSomethingElse(arg)
    | _ -> launchRocket()

W językach funkcyjnych powszechnie stosowane są listy ze wskaźnikami na następny element (ang. linked list) oraz krotki (ang. tuple). Dopasowania w F# pozwalają na czytelny zapis operacji na powyższych typach.

Listę można podzielić na n pierwszych elementów oraz ogon:

let rec processListValues = function
    | head :: tail when head = 1 -> head
    | head :: second :: tail when second = 1 -> head + second
    | head :: tail -> processListValues tail
    | [] -> 0

Możliwe jest również dopasowywanie do elementów w krotkach:

let processTuple = function
    | (a, b) when a + b = 0 -> a - b
    | (a, b) -> a + b

Ok, świetnie. Znamy kolejną konstrukcję w kolejnym dziwnym języku programowania. Co w niej takiego niezwykłego? Otóż dopasowywanie wzorców:

  • nie pozwala na pominięcie jakiejkolwiek wartości z możliwego zbioru wejść,
  • wymusza zwrócenie obiektu,
  • pozwala na operowanie na jakimkolwiek obiekcie

Pominięcie wartości i modyfikowanie stanu zmiennych zamiast zwrócenia obiektu są powszechnymi problemami konstrukcji switch. To samo dotyczy dopasowań - F# nie jest ograniczony do stałych.

Forward pipe

Ile razy zdarzyło wam się widzieć taki kod podobny do poniższego?

int cookedSmurfs = Cook(ConcoctMixture(SmurfsLocator.GetSmurfs().Where(s => s.Name != "Smurfette").ToList())).CountSmurfs();

Kolejne zagnieżdżenia wywołań niesamowicie utrudniają analizę kodu. Możemy oczywiście podzelić powyższy kod na mniejsze zmienne:

IEnumerable<Smurf> allSmurfs = SmurfsLocator.GetSmurfs();
IEnumerable<Smurf> tastySmurfs = allSmurfs.Where(s => s.Name != "Smurfette").ToList();
IMixture baseMixture = ConcoctMixture(tastySmurfs);
ICookedMixture cookedMixture = Cook(baseMixture);
int cookedSmurfs = cookedMixture.CountSmurfs();

Spoiler: CountSmurfs ma następującą postać:

public int CountSmurfs()
{
    // all Smurfs escaped soooo... f/u Gargamel
    return 0;
}

Wracając do tematu: F# pozwala na jeszcze prostsze połączenie wielu operacji. Wszystko to odbywa się dzięki operatorowi forward pipe. Operator ten przekazuje wynik operacji jako ostani argument do następnej fukcji:

let cookedSmurfs =
    getSmurfs()
    |> (smurfs -> smurfs.Where(s => s.Name != "Smurfette").ToList())
    |> concoctMixture
    |> cook
    |> countSmurfs

Wygląda dużo prościej i czytelniej, prawda?

Forward composition

Operator forward pipe pozwala na przekazywanie argumentów do kolejnych fukcji. A co jeżeli chcielibyśmy zapisać fukcję jako zbiór kolejnych operacji, nie nazywając zmiennych wejściowych? Z pomocą przychodzi, oczywiście, operator forward composition:

let countCookedSmurfs =
    >> (smurfs -> smurfs.Where(s => s.Name != "Smurfette").ToList())
    >> concoctMixture
    >> cook
    >> countSmurfs

let cookedSmurfs = countCookedSmurfs (getSmurfs())

Operatory forward pipe i composition różnią się tak naprawdę jedynie początkowym przekazaniem parametru. W przypadku forward pipe parametr przychodzi ze środka funkcji, natomiast w composition - z zewnątrz.

Podsumowanie

Wyrażenia same w sobie mogą powodować trudności w analizowaniu działania programu. Na szczęście składnia F# powoduje zwiększenie przejrzystości całego tego bałaganu. Moim zdaniem wyrażenia plus dedykowana składnia dają w wyniku dużo przejrzystszy kod, niż chociażby w przypadku ce-płotka. A co Wy o tym myślicie?

Materiały dodatkowe

F# for fun and profit - Expressions vs. Statements