Pure functions - okładka

Pure functions

Opublikowano Kategorie Czysty kodCzas czytania 8min

O wartości pure functions przypomniałem sobie podczas aktualizacji biblioteki Knex w jednym z projektów. Problem, na jaki trafiłem, uświadomił mi, że przedstawienie pojęcia pure functions jest świetnym tematem na publikację na bloga.

Na samym początku przedstawię Ci definicję oraz założenia pure functions. Następnie pokażę Ci, na jaki problem trafiłem w bibliotece Knex i co zrobiłem, by go rozwiązać. Na końcu pokażę Ci przykłady funkcji pure oraz impure. Dla wybranych funkcji impure pokażę, jak można sprowadzić je do postaci pure. Przykłady kodów są napisane w językach TypeScript lub JavaScript.

Czym są pure functions?

Aby móc nazwać funkcję pure, musi ona spełniać następujące założenia:

  • Dla tych samych argumentów wywołanie funkcji musi zwracać takie same wartości. Innymi słowy, pure functions są deterministyczne.
  • Funkcja pure nie może powodować efektów ubocznych (side effects).  Efektem ubocznym jest modyfikacja danych wejściowych, ale też np. zapytania HTTP, zdarzenia, jakie zachodzą w programie/systemie, operacje logowania, czy operacje na systemie plików.

Odniesienia do pure functions znajdziesz w wielu materiałach związanych z programowaniem funkcyjnym. Nie oznacza to jednak, że jest to zagadnienie ściśle powiązane z FP, wręcz przeciwnie. Pure functions są przydatne w KAŻDYM paradygmacie programowania. Wykorzystanie pure functions niesie kilka zalet:

  • determinizm kodu zwiększa jego przewidywalność i odporność na błędy;
  • deterministyczny kod cechuje się wyższą reużywalnością;
  • funkcje pozbawione efektów ubocznych sprawdzają się przy ich zrównoleglonym wykonywaniu. Zmniejsza się ryzyko na wystąpienie race condition;
  • dla wyników deterministycznych funkcji można skorzystać z mechanizmu memoizacji;
  • testy dla deterministycznych funkcji są znacznie prostsze w pisaniu i utrzymaniu;
  • zamiast ich wywołania, w miejsce docelowe można przekazać ekwiwalent w postaci ich wyniku (referential transparency), co może okazać przydatne przy refaktoryzacji, debuggingu czy eksperymentach z kodem;
  • w przypadku błędów, proces debuggowania i naprawiania kodu zwykle będzie łatwiejszy i szybszy.

Poniżej znajdziesz przykład trywialnej funkcji spełniającej opisane wymagania.


const vat: number = 0.23;

function calculatePrice( price: number ): number {
  return price + price * vat;
}

const price1: number = 100;
const price2: number = 200;

console.log( calculatePrice( price1 ), price1 );
console.log( calculatePrice( price2 ), price2 );

Zarówno warunek powtarzalności, jak i warunek braku efektów ubocznych został spełniony. Przykład poniżej jest natomiast klasycznym przykładem funkcji impure.


const user: Record<string, unknown> = {
  id: 123,
  username: 'admin',
  password: '********'
}

function hidePassword( user: Record<string, unknown> ): Record<string, unknown> {
  delete user.password;

  return user;
}

console.log( hidePassword( user ), user );

Przedstawiona funkcja jest funkcją impure, ponieważ nie spełnia warunku braku efektów ubocznych. Funkcja hidePassword modyfikuje obiekt wejściowy. Dzieje się tak, ponieważ przekazano referencję do obiektu, przez co modyfikacja obiektu wewnątrz funkcji hidePassword ma widoczny skutek w całym programie. Przekształcenie omawianej funkcji do postaci pure sprowadza się do zwrócenia kopii przekazanego użytkownika.


const user: Record<string, unknown> = {
  id: 123,
  username: 'admin',
  password: '********'
}

function hidePassword( user: Record<string, unknown> ): Record<string, unknown> {
  const { password, ...userCopy } = user;

  return userCopy;
}

console.log( hidePassword( user ), user );

Mój przypadek z Knex.js

Knex.js to query builder dla języka JavaScript i wspiera sporo popularnych rozwiązań bazodanowych. Aktualizacja Knexa do wersji 2.5.0 spowodowała, że w moim projekcie zaczęły pojawiać się błędy uwierzytelniania połączenia z bazą danych. Warto wspomnieć, że w moim kodzie Knex.js nie był jedynym źródłem zapytań do bazy danych, a wszystkie klienty bazodanowe wykorzystywały te same dane dostępowe. Niezbyt długie śledztwo pozwoliło mi dojść do wniosku, że funkcja tworząca instancję Knexa omyłkowo stała się funkcją impure. Zespół Knexa, w trosce o bezpieczeństwo zdecydował się ukrywać pole z hasłem w obiekcie zawierającym konfigurację połączenia z bazą danych. Chciałbym w tym miejscu pochwalić zespół Knexa za sprawną komunikację po zgłoszeniu przeze mnie problemu i szybkie przygotowanie fixa. W momencie publikacji tego artykułu fix nie jest jeszcze oficjalnie dostępny. Jeśli napotkałeś(aś) na opisany problem, możesz go rozwiązać, przekazując kopię obiektu konfiguracji do funkcji tworzącej instancję Knexa.


const config = {
    client: 'mysql',
    connection: {
        port: 8000,
        host: 'host.docker.internal',
        database: 'db',
        user: 'user',
        password: 'password'
    }
}

console.log( 'BEFORE', config );

const knexInstance = require( 'knex' )( {
  client: config.client,
  connection: {
    ...config.connection
  }
} );

console.log( 'AFTER', config );

Mniej i bardziej oczywiste przykłady funkcji impure

Niektóre z funkcji przedstawionych w tej części artykułu da się sprowadzić do postaci pure. Niektóre z przedstawionych funkcji będą funkcjami impure z natury.


const user: Record<string, unknown> = {
  id: 123,
  username: 'admin',
  credentials: { service: { password: '********' } }
}

function hidePassword( user: Record<string, unknown> ): Record<string, unknown> {
  delete user.credentials.service.password;

  return user;
}

Ta funkcja była już przedstawiona wcześniej, lecz z nieco prostszym obiektem wejściowym. W tym przypadku głównym problemem są referencje do obiektów przypisanych do kluczy credentials oraz service. Pracując z zagnieżdżonymi obiektami, należy pamiętać, by przy kopiowaniu obiektów skopiować również zagnieżdżone obiekty. O metodach kopiowania obiektów dowiesz się więcej z mojego artykułu o kopiowaniu obiektów w JavaScript. Po opakowaniu w funkcję _cloneDeep jednej z metod na klonowanie obiektów przedstawiona funkcja sprowadzona do pure function wygląda następująco:


function hidePassword( user: Record<string, unknown> ): Record<string, unknown> {
  const userClone: Record<string, unknown> = _cloneDeep( user );

  delete userClone.credentials.service.password;

  return userClone;
}

Kolejny przykład funkcji impure można zaobserwować w poniższej klasie.


class TaxCalculator {
  public constructor(
    private readonly _logger: ILogger
  ) {}

  public getTax( price: number, category: string ): number {
    return this._calculateTax( price, this._getTaxAmount( category ) );
  }

  private _calculateTax( price: number, taxAmount: number ): number {
    const tax: number = price * taxAmount;

    this._logger.log( 'Calculated tax: ' + tax );

    return tax;
  }

  private _getTaxAmount( category: string ): number {
     // ....
  }
}

W przestawionej klasie prywatna metoda _calculateTax jest idealnym kandydatem to przekształcenia jej do postaci pure function. Pozwala to na przeniesienia dodatkowej odpowiedzialności tzn. logowania informacji o obliczonym podatku poza tę funkcję. Logowanie można przenieść do publicznej metody getTax. Opisany side effect nie jest szczególnie bolesny, lecz w aplikacjach o większej skali przeoczenie takich drobnostek może czasami zaboleć. Zwróć uwagę, że gdyby zamiast loggera zostałby użyty console.log czy alert, to również _calculateTax byłaby funkcją impure.

Pozostałe przedstawione funkcje będą funkcjami impure ze swojej natury. Sprowadzenie ich do postaci pure jest zwyczajnie niemożliwe.

Najbardziej oczywistym przykładem funkcji impure są funkcje zwracające wartości losowe lub oparte o czas, np. funkcje zwracające daty na podstawie wartości timestamp, czy funkcje wykorzystujące do swoich obliczeń Math.random. Funkcjami impure będą również funkcje wykonujące zapytania po sieci, ponieważ nie ma gwarancji otrzymania identycznej odpowiedzi za każdym razem. Wynik może zaburzyć choćby brak połączenia z siecią. Funkcjami impure będą również wszystkie metody odpytujące lub manipulujące DOM. Nie ma gwarancji, że struktura DOM nigdy się nie zmieni. Również wszystkie funkcje manipulujące plikami na dysku będą funkcjami impure.


function getRandom(): number {
  return Math.random();
}

function getISODate(): Date {
  return new Date();
}

async function getUsers(): Promise {
  const response: Response = await fetch( 'https://test.pl/users' );

  return response.json();
}

function getUserIdElement(): HTMLElement | null {
  return document.getElementById( '#foo' );
}

function writeFile(): void {
  fs.writeFileSync( 'foo.txt', 'bar' );
}

Podsumowanie

W podsumowaniu myślę, że warto dodać, że wykorzystanie funkcji impure nie jest jednoznacznie złe. Jak w wielu innych aspektach „to zależy”. Wiele z przedstawionych przykładów funkcji jest impure ze swojej natury i ich wykorzystanie jest czymś normalnym. Chciałbym jednak podkreślić, że jeśli jest możliwe sprowadzenie funkcji impure do postaci pure, to zwykle warto będzie to zrobić.

Pytanie o pure functions jest jednym z pytań zawartych w moim e-booku 106 Pytań Rekrutacyjnych Junior JavaScript Developer.

Zachęcam do zapoznania się z materiałami dodatkowymi, zostawienia komentarza oraz udostępnienia tego wpisu!

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Zapisując się na mój mailing, otrzymasz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

0 komentarzy
Inline Feedbacks
View all comments