Jak (i po co) zacząć pisać funkcyjnie w C#?

17

Niezależnie od tego, czy masz już programistyczne doświadczenie, czy też dopiero zaczynasz swoją przygodę z programowaniem, jest całkiem prawdopodobne, że sformułowanie programowanie funkcyjne obiło Ci się o uszy.

Mimo rosnącej popularności, u większości programistów nie wywołuje ono przyspieszonego bicia serca. W tym artykule chciałbym Ci pokazać, że ten styl programowania może być źródłem sporej frajdy, a przy tym pomoże Ci pisać lepszy kod. Co więcej, pokażę Ci, jak w ogóle zabrać się do tematu i zacząć pisać funkcyjnie w C#.

Dlaczego warto?

Zacznijmy od najważniejszego, czyli dlaczego w ogóle masz poświęcać swój cenny czas na naukę programowania funkcyjnego (PF)? Jest co najmniej kilka powodów, dzięki którym nie będzie to czas stracony.

Będziesz robić mniej bugów

PF jest trochę jak zdrowe odżywianie. Wymusza na Tobie przestrzeganie surowo brzmiących reguł, ale w długim okresie jest dla Ciebie dobre i prowadzi do pożądanych skutków. Jak za chwilę zobaczysz, w PF jest dużo zasad, które na początku mogą wydawać się bardzo restrykcyjne, a czasem nawet absurdalne. Jednakże, przestrzegając tych reguł, masz znacznie mniejsze pole do popełnienia błędu. Twój kod jest czystszy i bardziej przewidywalny.

Wybierasz co chcesz

Nie musisz iść na całość i od razu przesiadać się na F# czy Haskella. Jest wiele funkcyjnych technik, które z powodzeniem można stosować w codziennej pracy bez robienia wielkiej rewolucji.

Pogimnastykujesz umysł

PF wymusza inne spojrzenie na rozwiązywane problemy. Takie wyjście ze strefy komfortu pozwoli Ci poszerzyć horyzonty i da Ci sporo satysfakcji. Jeśli masz już powoli dosyć programowania na co dzień, to dzięki PF możesz rozbudzić swoją pasję na nowo.

Czym jest programowanie funkcyjne?

Formalnie rzecz ujmując, PF to paradygmat programowania. Cóż to takiego? Paradygmat to zbiór wysokopoziomowych zasad i reguł dotyczących pisania kodu. Jakie paradygmaty wyróżniamy? Poniżej znajdziesz trzy najważniejsze (oczywiście jest ich więcej).

  • Proceduralny – program składa się z wielu funkcji i procedur. Każda z nich składa się z serii instrukcji, które ma wykonać komputer. Przykładowe języki: CPascal.
  • Obiektowy – najbardziej popularny spośród paradygmatów. W tym podejściu modelujemy rzeczywistość za pomocą zbioru obiektów, które wysyłają do siebie komunikaty. Przykładowe języki: JavaC#C++.
  • Funkcyjny – program składa się przede wszystkim z funkcji. Aplikację tworzymy poprzez składanie małych funkcji w co raz bardziej skomplikowane. Przykładowe języki: F#HaskellOcaml.

Podejścia proceduralne i obiektowe wpadają do większego worka o nazwie programowanie imperatywne. Istotą programowania imperatywnego jest wydawanie komputerowi instrukcji. Programista opisuje dokładnie w jaki sposób program ma się zachować.

W PF nie ma instrukcji, są za to wyrażenia. Program jest funkcją, wyliczającą rezultat na podstawie podanych argumentów.

Funkcje kojarzą nam się (słusznie) z matematyką. Być może zastanawiasz się właśnie, co taka matematyczna funkcja może mieć wspólnego z aplikacjami, które tworzysz na co dzień. Kluczowe jest zauważenie, że funkcje nie muszą wcale działać na liczbach. Co powiesz na funkcję, jako argument przyjmującą akcję wykonaną przez użytkownika i zwracającą obiekt opisujący, jak powinien zmienić się UI na skutek tej akcji?

Czy C# jest funkcyjny?

Zapytajmy Wikipedii:

C# is a multi-paradigm programming language encompassing strong typing, imperative, declarative, functional, generic, object-oriented (class-based), and component-oriented programming disciplines.

Pomimo, iż C# kojarzy się nam głównie jako język obiektowy, to jak widać możemy w nim znaleźć elementy z wielu różnych paradygmatów, w tym funkcyjnego. Ja uważam, że w C# jest naprawdę sporo elementów wspierających funkcyjność (zwłaszcza w porównaniu do np. Javy).

Podstawowe funkcyjne zagadnienia

Omówię teraz kilka podstawowych funkcyjnych koncepcji.

Funkcje obywatelami pierwszej kategorii

Jak już wiemy, w PF najważniejsze są funkcje. Aby móc programować choć trochę funkcyjnie, język musi pozwalać traktować funkcje jak wartości. Możemy zatem funkcję przypisać do zmiennej:

Func<Employee, double> calculateTax = 
	employee => employee.Salary * 0.19;

Funkcje mogą być też parametrami dla innych funkcji:

double CalculateTax(Employee employee, Func<Employee, double> getTaxRate)
{
	return employee.Salary * getTaxRate(employee);
}

W tym przykładzie funkcje CalculateTax przyjmuje jako parametr funkcję getTaxRate, określającą jaką metodę liczenia podatku zastosować dla danego pracownika.

Funkcje moga również zwracać funkcje:

Func<Employee, double> GetTaxRateCalculationMethod(Employee employee)
{
	if (employee.IsSelfEmployed)
	{
		return GetLinearTaxRate;
	}
	else
	{
		return GetProgressiveTaxRate;
	}

	double GetLinearTaxRate(Employee e) => 0.19;

	double GetProgressiveTaxRate(Employee e) => e.Salary > 32000 ? 0.32 : 0.19;
}

Ten przykład demonstruje przy okazji dość nowy element języka C#, czyli funkcje lokalne (pojawiły się w wersji 7.0 języka). Przydaje się on bardzo podczas pisania funkcyjnego kodu.

W powyższych przykładach używam określania funkcja w odniesieniu do metodwyrażeń lambdafunkcji anonimowych – z punktu widzenia programowania funkcyjnego, nie ma to znaczenia. Najważniejsze jest, że wszystkie te konstrukcje zwracają jakiś wynik. W związku z tym metoda zwracająca void nie jest funkcją.

Jak widać, C# pozwala na traktowanie funkcji jak wartości. W związku z tym, możemy powiedzieć, że w tym języku funkcje są obywatelami pierwszej kategorii.

Czyste funkcje (pure functions)

Wiemy już, że w PF funkcje traktujemy poważnie – będziemy je przekazywać, zwracać i przypisywać. Jednak funkcja funkcji nierówna. Jeśli chcemy programować funkcyjnie, to musimy poznać pojęcie czystej funkcji.

Funkcja jest czysta, jeśli spełnia następujące warunki:

  1. Jej wynik zależy tylko i wyłącznie od zadanych argumentów (tzw. referential transparency).
  2. Nie posiada efektów ubocznych.

Referential transparency

Skupmy się najpierw na punkcie pierwszym. Wydaje się to dość intuicyjne, jednak nie każda funkcja spełnia ten warunek. Spójrzmy na przykład na poniższą metodę:

private double taxRate = 0.19;

double CalculateTax(Employee employee)
{
	return employee.Salary * this.taxRate;
}

void DoStuff()
{
	var employee = new Employee { Salary = 2000 };
	this.taxRate = 0.15;
	Console.WriteLine(this.CalculateTax(employee));
	this.taxRate = 0.23;
	Console.WriteLine(this.CalculateTax(employee));
}

Widać na pierwszy rzut oka, że na konsolę wypisane zostaną dwie różne liczby. Wszystko dlatego, że CalculateTax odwołuje się do czegoś spoza funkcji – do pola taxRate. W związku z tym nie możemy powiedzieć, że wynik tej funkcji zależy wyłącznie od argumentów, z jakimi została zawołana.

Zobaczmy jeszcze jeden przykład funkcji, która nie jest pure.

TimeSpan CalculateAge(Employee employee)
{
	return DateTime.Now - employee.DateOfBirth;
}

W tym przypadku funkcja odwołuje się do DateTime.Now, które da inny wynik w zależności od tego, kiedy zostało wywołane. W jaki sposób możemy sprawić, aby ta funkcja była czysta?

TimeSpan CalculateAge(Employee employee, DateTime now)
{
	return now - employee.DateOfBirth;
}

Musimy wynieść odwołanie do świata zewnętrznego poza funkcję. W tym przypadku, zamiast wołać DateTime.Now, funkcja będzie przyjmować nowy parametr now.

Efekty uboczne

Kolejnym warunkiem, który musi spełniać czysta funkcja, jest brak efektów ubocznych. Czym są efekty uboczne?

  1. Wypisanie tekstu na konsolę.
  2. Wysłanie danych poprzez sieć.
  3. Modyfikacja pola klasy (w przypadku metody) lub pola statycznego.
  4. I wiele innych…

Wywołanie funkcji nie może zatem posiadać żadnych skutków ubocznych. Zadaniem funkcji jest wyłącznie wyliczenie jakiejś wartości na podstawie argumentów.

Ta reguła może Ci się wydawać bez sensu. Jak to, kod ma nie mieć efektów ubocznych? Czy w takim razie ktoś w ogóle zauważy jego wywołanie?

Możliwe są tutaj dwa podejścia. Języki czysto funkcyjne, takie jak Haskell, używają bardzo ciekawej koncepcji monad do ukrycia efektów ubocznych w taki sposób, że da się mimo wszystko pisać czyste funkcje.

Na początek jednak lepiej posłużyć się innym, bardziej pragmatycznym podejściem. Otóż tworząc program, nie dążymy do tego, aby wszystkie funkcje były czyste, a raczej do tego, aby funkcji czystych było jak najwięcej. Staramy się tak zorganizować nasz kod, aby funkcje nieczyste pojawiały się tylko tam, gdzie interakcja ze światem zewnętrznym jest konieczna.

Kilka słów o składni

Warto przy okazji wspomnieć o fajnym elemencie składni C# (który pojawił się w wersji 6.0 języka), który ma pewien związek z pisaniem czystych funkcji. Są to tak zwane expression-bodied methods. Przykładowo, powyższą funkcję CalculateTax możemy zapisać w następujący sposób:

TimeSpan CalculateAge(Employee employee, DateTime now) 
            => now - employee.DateOfBirth;

Taki zapis jest dużo bardziej zwięzły. Jaki ma to związek z czystymi funkcjami? Otóż jeśli funkcja ma nie posiadać efektów ubocznych, to wynika z tego, że będziemy w niej unikać używania instrukcji. Skoro wynik zależy wyłącznie od podanych argumentów, to ciałem funkcji może być po prostu wyrażenie. Dzięki expression-bodied methods możemy taką funkcję zapisać dużo zwięźlej.

Funkcje wyższego rzędu

Dochodzimy do momentu, w którym programowanie funkcyjne pokazuje swoją prawdziwą siłę. Mając do dyspozycji powyższe dwie koncepcje – funkcje jako obywatele pierwszej kategorii oraz czyste funkcje – jesteśmy gotowi na zmierzenie się z pojęciem funkcji wyższego rzędu.

Mam dla Ciebie dobrą wiadomość – prawdopodobnie od dawna znasz i używasz funkcji wyższego rzędu! Chodzi o LINQ, który jest bardzo mocno oparty na tej koncepcji.

Czym są zatem funkcje wyższego rzędu? Są to funkcje, które przyjmują inne funkcje jako argument. Rozpatrzmy przykład, w którym mając zadaną tablicę pracowników, chcemy znaleźć pracownika o najwyższej pensji. Tradycyjne, imperatywne rozwiązanie mogłoby wyglądać na przykład następująco:

Employee FindHighestEarner(Employee[] employees)
{
	if (employees.Length == 0)
	{
		return null;
	}

	var candidate = employees[0];
	foreach (var employee in employees)
	{
		if (employee.Salary > candidate.Salary)
		{
			candidate = employee;
		}
	}

	return candidate;
}

W tym podejściu zaczynamy od wprowadzenia zmiennej przechowującej kandydata na wynik (czyli pracownika z najwyższą pensją). Następnie, przechodzimy po tablicy employees i jeśli znajdziemy pracownika o pensji wyższej niż kandydat, to ustawiamy zmienną candidate na tego pracownika. Po przejściu całej tablicy candidate będzie wskazywał na pracownika z najwyższą pensją.

Spójrzmy teraz na funkcyjną implementację.

Employee FindHighestEarner(Employee[] employees)
{
	return employees.Aggregate((candidate, employee) =>
		employee.Salary > candidate.Salary 
			? employee : candidate
		);
}

Jak widać, kod nam się znacznie uprościł i skrócił. W tym rozwiązaniu użyłem funkcji Aggregate (pochodzącej z LINQ), przyjmującej inną funkcję jako parametr. Przyjmowana funkcja opisuje jak, mając element tablicy oraz wynik częściowy wyliczony dla poprzednich elementów, skonstruować wynik częściowy, uwzględniający ten element. W naszym przypadku wynikiem jest pracownik o najwyższej pensji. Mając pracownika najlepszego spośród pierwszych trzech elementów tablicy oraz pracownika czwartego, jesteśmy w stanie powiedzieć, kto jest najlepszym pracownikiem dla pierwszych czterech elementów tablicy.

Aggregate jest przykładem funkcji wyższego rzędu. Prawdopodobnie znasz inne funkcje wyższego rzędu takie jak Select czy Where. Chcąc pisać funkcyjnie w C# warto zaznajomić się ze wszystkimi funkcjami występującymi w LINQ i zacząć ich używać zamiast imperatywnych rozwiązań. Powstały w ten sposób kod jest dużo bardziej zwięzły i w lepszy sposób wyraża intencje programisty.

Jaki jest związek funkcji wyższego rzędu z poprzednimi koncepcjami? Język musi umożliwiać przekazywanie funkcji jako parametr innych funkcji, aby funkcje wyższego rzędu były możliwe do zaimplementowania. Co więcej, bardzo często jako argument funkcji wyższego rzędu będziemy podawać funkcję czystą.

Currying i częściowa aplikacja

Na koniec chciałbym Ci opowiedzieć o idei bardzo ważnej dla programowania funkcyjnego. Otóż jeśli chcemy budować nasz program z funkcji, to potrzebujemy narzędzi pozwalających nam w prosty i wygodny sposób te funkcje ze sobą komponować. Jednym z takich narzędzi jest currying. Wróćmy na chwilę do przykładu z przekazywaniem metody wyznaczania odsetek podatku do CalculateTax.

double CalculateTax(Employee employee, Func<Employee, double> getTaxRate)
{
	return employee.Salary * getTaxRate(employee);
}

Zapiszę teraz tę funkcję w inny sposób:

Func<Employee, double> CalculateTax(Func<Employee, double> getTaxRate) => 
                employee => employee.Salary * getTaxRate(employee);

Co tu się wydarzyło? Pierwotnie funkcja CalculateTax przyjmowała dwa argumenty. Zmieniłem ją w taki sposób, że przyjmuje ona jeden argument getTaxRate, a następnie zwraca funkcję, która przyjmuje kolejny argument employee. Taki zabieg nazywamy w programowaniu funkcyjnym rozwijaniem (ang. currying). Co daje nam taka dziwna operacja?

Dzięki takiemu zapisowi możemy w bardzo łatwy sposób tworzyc nowe funkcje poprzez podanie tylko jednego argumentu do CalculateTax. Przykładowo, możemy w prosty sposób stworzyć funkcje, które wyliczają podatek liniowy lub progresywny:

var calculateTaxLinear = CalculateTax(GetLinearTaxRate);
var calculateTaxProgressive = CalculateTax(GetProgressiveTaxRate);

Jest to bardzo istotna operacja w programowaniu funkcyjnym, ponieważ dzięki niej możemy uniknąć tworzenia niepotrzebnych funkcji – zamiast tego składamy istniejące.

Podsumowanie

Powyższe zagadnienia to zaledwie wierzchołek góry lodowej. Stanowią one jednak podstawę do dalszej nauki. W ramach podsumowania, poniżej znajdziesz listę wskazówek, które pozwolą Ci tworzyć bardziej funkcyjny kod w C#.

  1. Korzystaj z możliwości, jakie daje Ci język i twórz funkcje parametryzowane innymi funkcjami. Ten mechanizm często możesz wykorzystać np. zamiast dziedziczenia.
  2. Staraj się, aby jak największa część Twojego kodu znajdywała się w czystych funkcjach.
  3. Wykorzystuj funkcje wyższego rzędu. Jeśli piszesz pętlę, to prawdopodobnie da się ją przepisać jako wywołanie funkcji wyższego rzędu.
  4. Jeśli tworzysz wiele podobnych funkcji, to zastanów się, czy możesz tego uniknąć wykorzystując currying.

A to dopiero początek! Do przeczytania już wkrótce w kolejnych odsłonach tego cyklu!

Nie przegap kolejnych postów!

Dołącz do ponad 9000 programistów w devstyle newsletter!

Tym samym wyrażasz zgodę na otrzymanie informacji marketingowych z devstyle.pl (doh...). Powered by ConvertKit
Share.

About Author

Jestem programistą full-stack, na co dzień piszącym w .NET oraz JavaScripcie. Pasjonuję się programowaniem funkcyjnym, a zwłaszcza jego zastosowaniom w tworzeniu nowoczesnych aplikacji webowych. Lubię dzielić się wiedzą na meet-upach, konferencjach oraz szkoleniach. Prowadzę bloga programistycznego.

17 Comments

  1. Super artykuł. Jako programista z “przeciwnego obozu” ;P mogę powiedzieć, że dla osób które znają Javę to warto chociaż poznać Kotlina, bo wszystko co jest opisane w tym artykule to jest możliwe również tam, a sam język jest bardzo podobny. Do tego (na tyle na ile się dowiedziałem co to jest LINQ, jeżeli pisze źle to mnie wyprowadźcie z błędu) Kotlin ma bibliotekę Arrow która też ułatwa programowanie funkcyjne.

  2. Mateusz Kopeć on

    “Podejścia proceduralne i funkcyjne wpadają do większego worka o nazwie programowanie imperatywne” =》”proceduralne i obiektowe”

    A poza tym: cześć Miłosz! Kiedyś razem pracowaliśmy w firmie na Puławskiej:)

    • drobna uwaga: Sekcja nazwana Funkcyjna kompozycja a traktuje o częściowej aplikacji zamiast o złożeniu funkcji ;)

      Dzięki za uwagę! Dla mnie częściowa aplikacja, to też kompozycja – dzięki niej możemy wziąć dwie funkcje i dostać nową (jak w przykładzie w artykule). Ale formalnie kompozycja to faktycznie składanie, więc poprawię nagłówek żeby nie mieszać. O składaniu będzie sporo w kolejnych artykułach.

      IMHO programowanie funkcyjne w C# to bardziej kwiatek do kożucha, i jak zamierza się programować funkcyjnie to lepiej robić to w narzędziach do tego dedykowanych
      dla porównania złożenie w F#

      Totalnie się zgadzam! Tylko że mam wrażenie, że wiele osób dostaje alergii jak słyszy “F#”, więc wydaje mi się że C# jest lepszym wyborem do popularyzacji programowania funkcyjnego. Z pewnością jednak w którymś z artykułów zasygnalizuję, że to wszystko można zrobić w F# dużo ładniej, czyściej i krócej.

  3. Bardzo fajny art, przejrzyście napisany, z dobrze dobranymi przykładami. Udowadnia też że nie trzeba od razy uczyć się F#, żeby zacząć pisać funkcyjnie. Można np. przepisać jedną klasę w istniejącym projekcie i czegoś nowego się nauczyć.

  4. Piotrek Chmielowski on

    A ja bym jednak się sprzeczał czy programowanie obiektowe jest zawsze imperatywne. Wydaję mi się, że jednak oś imperatywne-deklaratywne jest ortogonalna do osi obiektowe-nie obiektowe i można pisać kod obiektowy, który jest deklaratywny.
    Rzuć okiem na Readme do biblioteki Cactoos (https://github.com/yegor256/cactoos), moim zdaniem to jest dobry przykład deklaratywnego OOP.

    • Ciekawa koncepcja. Faktycznie jest deklaratywnie i używa się obiektów. Dla mnie jednak istotą programowania obiektowego jest to, że obiekty wysyłają do siebie komunikaty, co z definicji nie jest deklaratywne.

  5. Jestem webdeveloperem i artykuł zainteresował mnie PF, ale nie wiem jak to ugryźć. Do czego stosowany jest taki język? Gdzie znajduje zastosowanie? Na czym polegały (biznesowo, jeśli możecie podać taki przykład)?

    • Dokładnie, w następnym artykule będzie bardziej życiowy przykład zastosowania programowania funkcyjnego.

      Generalnie można jak najbardziej stosować w aplikacjach webowych – aplikacja webowa to nic innego jak funkcja przekształcająca requesty HTTP w response’y :) W F# są frameworki do aplikacji webowych – np. https://suave.io.

      Są firmy, który cały swój stack opierają na funkcyjnych technologiach – np. https://www.janestreet.com/

    • Programowanie funkcyjne to styl pisania, a nie sam język. Jako WebDeveloper pewnie programujesz dużo w JavaScripcie, który jest jeszcze bardziej przyjazny funkcyjności niż C#, więc proponuję zacząć naukę od własnego podwórka :D

    • U mnie się świetnie sprawdza w aplikacjach Web (wspomniany przez Miłosza, suave + SQLProvider), a jeszcze lepiej sprawdza się w sofcie komunikującym się z PLC (ADS, Modbus), czy sieciach samochodowych (CAN, LIN)