Paweł Łukasiewicz
2018-11-06
Paweł Łukasiewicz
2018-11-06
Udostępnij Udostępnij Kontakt
Wstęp

Nie ma niczego zaskakującego w stwierdzeniu, że jednym z największych wyzwań w programowaniu systemów biznesowych jest złożoność. Zmiany były, zmiany będą – zmiany są nieuniknione. Problem narasta w momencie gdy wprowadzona funkcjonalność jest skomplikowana. Może to prowadzić do trudności w zrozumieniu kodu oraz samej weryfikacji oprogramowania. To niestety nie jest koniec problemów, dochodzi do opóźnień związanych z wydaniem kodu, pojawiają się nowe błędy w trakcie implementacji. Powyższe punkty mogą prowadzić również do powstania niespodziewanych zachowań lub efektów ubocznych działania naszych aplikacji. Każdy zna to powiedzenie: Is it not a bug, it is a feature Wszystko to wiąże się z opoźnieniem pracy nad projektem, a w efekcie, może zakończyć się nawet całkowitym niepowodzeniem.

Styl programowania imperatywnego, taki jak programowanie obiektowe, ma zdolność minimalizowania złożoności do określonego poziomu, jeżeli w odpowiedni sposób podchodzimy do tworzenia abstrakcji oraz ukrycia złożoności. Obiekt klasy charakteryzuje się pewnymi zachowaniami, które można zaimplementować bez dbałości o złożoność implementacji. Jednakże klasy, które zostały napisane poprawnie, będą miały wysoką spójność oraz nie będą silnie skojarzone z innymi klasami. Dzięki temu wzrośnie użyteczność tak przygotowanego kodu a złożoność zostanie utrzymana na rozsądnym poziomie.

Programując w języku C# nauczyłem się procesu myślowego mającego tendencję do tworzenia sekwencji instrukcji, których celem jest rozwiązanie pewnego problemu poprzez odpowiedni projekt hierachi klas, ukrycie szczegłów implementacji (hermetyzacja), tworzenie abstrakcji oraz przeciążanie metod, które ma tendencję do zmiany stanu programu – dochodzi do modyfikacji pamięci. Zawsze istnieje możliwość, że dowolna liczba wątków może próbować odczytać lokalizację pamięci, która nie podlega synchronizacji. Może to prowadzić do nieoczekiwanych wyników działania naszego algorytmu. Nie jest to pożądane gdy programista stara się realizować programowanie współbieżne.

Nie zmienia to faktu, że wciąż można napisać program imperatywny, którego kompletny kod jest bezpieczny dla wątków i wspiera współbieżność. Ciągle jednak trzeba będzie zwracać uwagę na wydajność, która jest trudna do utrzymania w środowisku współbieżnym. Nawet jeżeli programowanie równoległe poprawia efektywność działania algorytmów, jest niezwykle trudno przekształcić działający kod sekwencyjny na równoległy ponieważ znaczna część kodu musi zostać zmodyfikowana, żeby być w stanie operować na wielu wątkach.

Jakie jest rozwiązanie?

Warto rozważyć programowanie funkcyjne. Jest to paradygmat programowania wywodzący się z idei starszych niż pierwsze komputery, kiedy to dwóch matematyków wprowadziło teorię zwaną lambda calculus. Teoria ta dostarczyła opis, która obliczenia matematyczne traktował jako ocenę funkcji – dochodziło do ewaluacji wyrażeń a nie wykonywania obliczeń a tym samym zmiany stanu i mutowania danych.

Dzięki temu jest zdecydowanie łatwiej zrozumieć i wytłumaczyć kod, a co najważniejsze, zminimalizować niepożądane zachowania. W takim wypadku samo wykonywanie testów jednostkowych jest zdecydowanie prostsze.

Języki funkcyjne

Już sama analiza języków funkcyjnych jest interesująca. Języki takie jak Lisp, Clojure, Erlang, OCaml czy Haskell były używane w wielu komercyjnych i przemysłowych aplikacjach przez wiele różnych organizacji. Każdy z tych języków podkreśla różne cechy i aspekty stylu funkcjonalnego. ML jest uniwersalnym językiem programowania funkcjonalnego a język F# powstał jako język programowana funkcjonalnego dla .NET w roku 2002. Łączy on zwięzły, wyrazisty i kompozycyjny styl programowania funkcjonalnego ze środowiskiem uruchomieniowym, bibliotekami, interoperacyjnością(w przypadku oprogramowania, cecha, która pozwala na realizowanie funkcjonalności bez problemów czy zakłóceń) i modelem obiektowym .NET.

Warto również zauważyć, że wiele obecnych języków programowania jest wystarczająco elastycznych, aby pozwolić na obsługę wielu paradygmatów. Dobrym przykładem jest C#, który zapożyczył wiele funkcji od ML’a oraz Haskell’a. Prostym przykładem może być LINQ, który promuje styl deklaratywny i niezmienność – nie modyfikuje podstawowej kolekcji na której operuje.

Używając języka C# jesteśmy w stanie połączyć kilka paradygmatów, aby mieć pełną kontrolę i elastyczność w wyborze podejścia, który najlepiej pasuje do rozwiązania problemu.

Fundamentalne zasady

Programowanie funkcyjne jest programowaniem przy użyciu funkcji matematycznych. Za każdym razem gdy podawane są te same argumenty, funkcja matematyczna zwróci ten sam wynik. Dodatkowo funkcja musi być opisana w taki sposób, aby prezentować wszystkie informacje o możliwym wejściu oraz o wynikach, które generuje. Odbywa się to przez postrzeganie dwóch prostych zasad: przejrzystość referencyjna oraz uczciwość funkcjonalna.

Przejrzystość referencyjna Pojęcie to pozwala nam określić wynik zastosowania funkcji patrząc jedynie na wartości jej argumentów. Oznacza to, że taka funkcja powinna działać tylko na wartrościach, które my przekazujemy i nie powinna odnosić się do stanu globalnego. Spójrz na poniższy przykład:

public int CalculateElapsedDays(DateTime from)
{
	DateTime date = DateTime.Now;

	return (date - from).Days;
}
Ta funkcja nie jest referencyjnie przejrzysta. Dlaczego? Dzisiejszego dnia zwróci wynik, który jutro będzie już nieaktualny. Powodem jest to, że odnosi się do globalnej wartości DataTime.Now.

Pojawia się pytanie: "Czy taka funkcja można zostać przekształcona w funkcję przejrzystości referencyjnej?" – Tak, spójrz na poniższy przykład:

public static int CalculateElapsedDays(DateTime from, DateTime now)
	=> (now - from).Days;
Funkcja taka operuje tylko na parametrach, które my przekazujemy – wyeliminowaliśmy zależność od stanu globalnego.

Uczciwość funkcjonalna Definicja funkcji przygotowana jest w taki sposób, iż przekazuje nam wszystkie informacje dotyczące możliwych danych wejściowych oraz wyniku, który nam zwróci:

public static int DivideOperation(int numerator, int denominator)
	=> numerator / denominator;
Czy ta funkcja jest referencyjne przezroczysta? – Tak.

Czy kwalifikuje się jako poprawna funkcja matematycza? – Niestety nie.

Powód jest prozaiczny. Z jednej strony mamy określone dane wejściowe jako dwie liczby całkowite a wynikiem jest również liczba całkowita. Taki scenariusz nie jest jednak prawdziwy dla każdego przypadku...

Spróbujmy wywołać funkcję w takiej postaci:

static void Main(string[] args)
{
	var result = DivideOperation(8, 0);
}
Rzucony wyjątek DividedByZero dla nikogo nie jest niespodzianką. Sygnatura funkcji nie przekazuje nam zatem wystarczającej informacji o wynikach operacji.

Jak przekonwertować powyższą funkcję na funkcję matematyczną? Typ dzielnika/mianownika powien zostać zmieniony w poniższy sposób:

public static int DivideOperation(int numerator, NonZeroInt denominator)
	=> numerator / denominator.Value;
NonZeroInt jest typem niestandardowym, która może zawierać dowolną liczbę całkowitą inną niż zero. Dopiero teraz nasza funkcja spełnia wszystkie kryteria ponieważ przekazuje informację o możliwych danych wejściowych oraz o wyniku generowanym przez tę funkcję.

Pomimo prostych zasad programowanie funkcjonalne wymaga wielu praktyk, które mogą wydawać się niezywkle skomplikowane i przytłaczające dla wielu programistów. Poniżej przybliżę kilka podstawowych aspektów programowania funkcyjnego do których zaliczamy: "Funkcje jako typ pierwszoklasowy", "Funkcje wyższego rzędu" oraz "Czyste funkcje".

Funkcje jako typ pierwszoklasowy

Jeżeli funkcje są typem pierwszoklasowym mogą byc używane jako dane wejściowe lub wyjściowej w dowolnej, innej funkcji. Mogą być również przypisane do zmiennych, przechowywane w kolekcjach – podobnie jak wartości innych typów:

static void Main(string[] args)
{
	Func<int, bool> isMod2 = x => x % 2 == 0;
	var numbers = Enumerable.Range(1, 10);

	var evenNumbers = numbers.Where(isMod2);
}
Powyższy kod pokazuje, iż funkcję są rzeczywiście typami pierwszoklasowymi ponieważ możemy przypisać funkcję (x => x & 2 == 0) do zmiennej isMod2, która z kolei jest przekazana jako argument do metody rozszerzającej IEnumerable, tj. Where.

Traktowanie funkcji jak zwykłych wartości jest konieczne w funkcyjnym stylu programowania gdyż daje możliwość definiowania funkcji wyższego rzędu.

Funkcje wyższego rzędu (HOF)

HOF to funkcje, które przyjmują jedną lub więcej funkcji jako argumenty lub zwracają funkcję jako rezultat – bądź jedno i drugie. Wszystkie pozostałe funkcje są funkcjami pierwszego rzędu.

Rozważmy teraz ten sam przykład wyznaczania reszty z dzielenia, w którym list.Where dokonuje filtracji w celu określenia, który numer będzie zawarty na ostatecznej liście na bazie predykatu dostarczonego przez funkcję wywołującą. Podanym tutaj predykatem jest funkcja isMod2 a metoda rozszerzająca Where interfejsu IEnumerable jest funkcją wyższego rzędu. Implementacja Where wygląda w następujący sposób:

public static class ExtensionMethods
	{
		/// <summary>
		/// Metoda rozszerzająca Where
		/// </summary>
		/// <typeparam name="T"></typeparam>
		/// <param name="ts"></param>
		/// <param name="predicate"></param>
		/// <returns></returns>
		public static IEnumerable<T> Where<T>(this IEnumerable<T> ts, Func<T, bool> predicate)
		{
			foreach (T t in ts)      // 1
			{
				if (predicate(t))	// 2
					yield return t;  
			}
		}
	}
  1. zadanie polegające na iteracji po liście jest szczegółową implementacją Where
  2. kryterium określającym, które elementy są uwzględnione, jest zależne od funkcji wywołującej

Funkcja wyższego rzędu Where dodaje daną funkcjonalność do każdego elementu listy. Funkcję wyższego rzędu można zaprojektować tak, aby warunkowo mogła być również zastosowana do kolekcji.

Czyste funkcje

Czyste funkcje są funkcjami matematycznymi, które są zgodne z dwiema podstawowymi zasadami, które omówiliśmy wcześniej – przejrzystość referencyjna oraz uczciwość funkcyjna. Dodatkowo, użycie czystych funkcji nie powoduje żadnych efektów ubocznych – nie dochodzi do zmiany globalnych parametrów oraz argumentów wejściowych. Funkcje takie są łatwe do przetestowania i uzasadnienia ich implementacji. Ponieważ wynik zależy tylko od danych wejściowych, kolejność obliczeń nie jest ważna. Te cechy są niezwykle ważne, aby program był zoptymalizowany pod kątem współbieżości, wartościowania leniwego (wyznaczanie wartości argumentów funkcji tylko wtedy, kiedy są potrzebne) oraz buforowania (caching).

Przejdźmy przez przykład aplikacji konsolowej, która mnoży listę liczb przez 2 i w czytelnej postaci wyświetla listę przeprowadzonych operacji:

// ExtensionMethods.cs
public static class ExtensionMethods
{
	/// <summary>
	/// Metoda rozszerzająca Where
	/// </summary>
	/// <typeparam name="T"></typeparam>
	/// <param name="ts"></param>
	/// <param name="predicate"></param>
	/// <returns></returns>
	public static IEnumerable<T> Where<T>(this IEnumerable<T> ts, Func<T, bool> predicate)
	{
		foreach (T t in ts)
		{
			if (predicate(t))
				yield return t;
		}
	}

	/// <summary>
	/// Metoda rozszerzająca mnożąca liczbę x2
	/// </summary>
	/// <param name="value"></param>
	/// <returns></returns>
	public static int MultiplyBy2(this int value)  // 1
		=> value * 2;
}

// FormatMultiplicationOperation.cs
public static class FormatMultiplicationOperation
{
	static int counter;

	static string Counter(int value)
		=> $"{++counter} x 2 = {value}"; // 2

	public static List<string> Format(List<int> list)
	{
		return list.Select(ExtensionMethods.MultiplyBy2) // 3
			.Select(Counter) // 3
			.ToList();
	}
}
// Program.cs
static void Main(string[] args)
{
	var list = FormatMultiplicationOperation.Format(Enumerable.Range(1, 10).ToList());

	foreach (var item in list)
	{
		Console.WriteLine(item);
	}

	Console.ReadLine();
}
  1. czysta funkcja;
  2. funkcja nieczysta – doprowadza do zmiany stanu globalnego;
  3. oba typy funkcji mogą być stosowane podobnie

Powyższy kod działa dobrze ponieważ operujemy tylko na 10 liczbach. Co jeśli chcemy wykonać tę samą operację dla większego zbioru danych, szczególnie gdy mamy bardzo wydajny procesor? Czy takie zadanie ma sens wykonywane równolegle?

Możemy tutaj użyć równoległej implementacji LINQ, tj. PLINQ praktycznie bez większych problemów. Wystarczy posłużyć się metodą AsParallel:

public static List<string> Format(List<int> list)
{
	return list.AsParallel()
		.Select(ExtensionMethods.MultiplyBy2)
		.Select(Counter)
		.ToList();
}
Tutaj jednak jedno ostrzeżenie – oba typy zastosowanych funkcji nie współgrają dobrze ze sobą przy przetwarzaniu równoległym.

Co mam na myśli? Funkcja Counter jest funkcją nieczystą. Równoległe przetwarzanie będzie charakteryzowało się wieloma wątkami czytania i aktualizowania danych – nie będzie żadnego blokowania. Program zakończy się utratą niektórych aktualizacji wartości przez co pojawią się nieprawidłowe wyniki:

1 x 2 = 2
3 x 2 = 4
6 x 2 = 6
5 x 2 = 8
7 x 2 = 10
8 x 2 = 12
9 x 2 = 14
10 x 2 = 16
2 x 2 = 18
4 x 2 = 20
Jak zatem nie dopuścić do pojawienia się takiego problemu? Jedną z możliwości jest uniknięcie zmiany stanu globalnego. Możemy wygenerować listę potrzebnych liczników i dokonać mapowania ich na podaną listę elementów:
public static List<string> Format(List<int> list)
{
	return list.AsParallel() // przetwarzanie równoległe
		.Select(ExtensionMethods.MultiplyBy2)
		.Zip(Range(1, list.Count), (val, counter) => $"{counter} x 2 = {val}") // zmiana na potrzeby wywołania równoległego
		.ToList();
}
Użycie Zip oraz Range pozwala na przepisanie metody Format. Zip może być używana jako metoda rozszerzająca – możemy napisać własną implementację przy pomocy prostszej składni. Po tej zmianie, metodę Format możemy nazywać czystą. Możemy teraz przejść do wywołania równoległego, które jest niemal identyczne z wywołaniem synchronicznym z pominięciem szczegółów odpowiednio skomentowanych w kodzie.
1 x 2 = 2
2 x 2 = 4
3 x 2 = 6
4 x 2 = 8
5 x 2 = 10
6 x 2 = 12
7 x 2 = 14
8 x 2 = 16
9 x 2 = 18
10 x 2 = 20

Oczywiście programowanie funkcyjne nie zawsze jest tak proste jak w powyższym scenariuszu. Mam jednak nadzieję, że powyższy artykuł wprowadził Cie w podstawy tak, abyś mógł poradzić sobie z kwestiami związanymi z współbieżnością i równoległością.