10 Złożone funkcje

Funkcje są podstawą działania w językach programowania. Rozdział 3 wprowadził do podstawowych kwestii związanych z funkcjami - jak się używa wbudowanych funkcji oraz jak się tworzy proste nowe funkcje. Tworzenie bardziej złożonych funkcji czy też zbiorów funkcji wymaga przemyślenia tego nie tylko jak się będą one nazywać, ale też tego jak mogą one zostać użyte przez inne osoby. W tym rozdziale zostanie podanych kilka porad w jaki sposób budować funkcje przyjazne innym użytkownikom oraz w jaki sposób tworzyć odpowiednie komunikaty błędów, ostrzeżeń czy wiadomości. Dodatkowo, nastąpi także wprowadzenie do kolejnego paradygmatu programowania - programowania obiektowego.

10.1 API

Interfejs programistyczny aplikacji (ang. application programming interface, API) to zbiór sposobów komunikacji pomiędzy różnymi komponentami oprogramowania. Najszerzej mówiąc API określa w jaki sposób następuje interakcja z kodem i my w tej sekcji skupimy się na tej definicji. Warto jednak pamiętać, że często osoby, które używają tego skrótu mają tak na prawdę na myśli RESTful API, czyli API które powala na komunikację pomiędzy komputerami poprzez protokół HTTP.

Dobrze zaprojektowane API uławia zarówno rozwijanie oprogramowania, jak i jego używanie. Podstawowe elementy przemyślanego API w R obejmują nazwy funkcji, ich argumenty, oraz tzw. stabilność typu (ang. type stability).

Funkcje wewnątrz pojedynczego pakietu powinny być nazywane konsekwentnie używając tylko jednej konwencji nazywania (sekcja 2.4.1). Sama nazwa powinna w zwięzły sposób przekazywać jakie jest działanie funkcji. Dodatkową możliwością jest używanie w jednym pakiecie funkcji rozpoczynających się od takiego samego prefiksu. Przykładowo, większość nazw funkcji w pakiecie landscapemetrics rozpoczyna się od liter lsm_, np. lsm_l_ent() (Hesselbarth et al. 2020).

Podobnie należy stosować tylko jedną konwencję przy nazywaniu argumentów funkcji, a nazwy argumentów powinny być informacyjne, ale jednocześnie zwięzłe. W przypadku, gdy taki sam rodzaj danych wejściowych jest oczekiwany w różnych funkcjach, koniecznie jest aby zawsze ten argument był tak samo nazwany. Podobnie należy zadbać o spójną kolejność podobnych argumentów w funkcjach jednego pakietu.

Stabilność typu oznacza, że używając jednej klasy danych wejściowych funkcja zawsze zwróci obiekt jednej klasy. Poniższy przykład użycia funkcji grep() pokazuje, że nie ma ona stabilności typu. Zalecane jest unikanie tworzenia funkcji bez stabilności typu.

tekst = c("kołdra", "kordła", "pościel")
grep("^[k].", x = tekst)
#> [1] 1 2
grep("^[k].", x = tekst, value = TRUE)
#> [1] "kołdra" "kordła"

Dodatkowym elementem API może być określenie domyślnych parametrów funkcji. Poniższa funkcja, potegowanie() ma na celu podnoszenie wartości wejściowego wektora (x) do wybranej potęgi (w). Domyślamy się jednak, że większość użytkowników jest zainteresowana używaniem tej funkcji do podnoszenia wartości do drugiej potęgi i dlatego też ustalamy, że domyślnie argument w przyjmuje wartość 2.

potegowanie = function(x, w = 2){
  x ^ w
}

W tej sytuacji, gdy użytkownik poda tylko jeden argument do funkcji potegowanie() to podany wektor zostanie podniesiony do kwadratu.

potegowanie(2)
#> [1] 4

Będzie to identyczne z działaniem funkcji, gdy użytkownik ręcznie zdefiniuje drugi argument jako dwa (w = 2).

potegowanie(2, w = 2)
#> [1] 4

W sytuacji, gdy użytkownika interesuje inna wartość w niż domyślna, może on ją zmodyfikować i otrzyma odpowiedni wynik.

potegowanie(2, w = 3)
#> [1] 8

10.2 Obsługa komunikatów

W sekcji 3.9 omówiliśmy trzy podstawowe rodzaje komunikatów: błędy, ostrzeżenia i wiadomości. Teraz zobaczmy jak te zaimplementować we własnych funkcjach i kiedy powinny być one użyte.

Obsługa błędów w funkcjach ma na celu ochronę użytkownika przed nieodpowiednim zachowaniem funkcji. Komunikat błędu powinien ułatwiać użytkownikowi zrozumienie problemu oraz jego rozwiązanie. Zazwyczaj komunikat błędu przyjmuje jedną z trzech form: (1) określenie problemu, np. Argument 'x' musi być zmienną numeryczną, a nie znakową., (2) lokalizacja błędu, np. Kolumna 'abc' nie istnieje w obiekcie 'y'., (3) porada, np. Did you mean 'Species == "setosa"'?. Oczywiście te wymienione formy można łączyć.

Ważne jest też, aby funkcja kończyła swoje działanie jak najszybciej po napotkaniu, np. błędnych wartości wejściowych. Żadnej użytkownik nie chce czekać na zakończenie wykonywania długiej funkcji zanim dostanie komunikat błędu. Więcej informacji o strukturze komunikatów błędów można znaleźć na https://style.tidyverse.org/error-messages.html.

Do zatrzymania działania funkcji i wyświetlenia komunikatu błędu służy stop().

stop("To jest komunikat błędu.")
#> Error in eval(expr, envir, enclos): To jest komunikat błędu.

Ostrzeżenia mogą być używane w wielu różnorodnych sytuacjach, np. kiedy chcesz poinformować użytkowników o tym, że dana funkcja zostanie wygaszona lub przeniesiona do innego pakietu. Komunikaty ostrzeżenia tworzyć się używając funkcji warning().

warning("To jest komunikat ostrzeżenia.")
#> Warning: To jest komunikat ostrzeżenia.

Wiadomości mają na celu poinformowanie użytkownika na temat działania pakietu lub funkcji. Są one wykorzystywane podczas wczytywania niektórych pakietów. Innym przykładem jest informowanie na temat działania funkcji w tle - pobierania danych, zapisywania do pliku, czy przeliczania cząstkowych parametrów. Do wyświetlenia wiadomości służy funkcja message().

message("To jest komunikat wiadomości.")
#> To jest komunikat wiadomości.
Działanie funkcji message() jest zbliżone do funkcji cat() czy print(). Różni je jednak cel w jakim są użyte. Rolą funkcji message() jest przekazanie informacji od twórcy do użytkownika, natomiast celem funkcji tj. cat() jest zapytanie użytkownika w pewnej kwestii.

Przykład użycia trzech podstawowych rodzajów komunikatów można zobaczyć w poniższej funkcji minus_1(). Ta funkcja przyjmuje wartość numeryczną, od której odejmuje jeden, a na końcu zwraca wartość bezwzględną (abs(x - 1)).

minus_1 = function(x){
  if(is.character(x)){
    stop("Argument `x` musi być zmienną numeryczną, a nie znakową.")
  } else if(is.logical(x)){
    warning(paste("Argument `x` jest zmienną logiczną.",
                  "Czy nie chcesz użyć zmiennej numerycznej?"))
  } else {
    message("Wow. Argument `x` jest oczekiwaną zmienną numeryczną.")
  }
  abs(x - 1)
}

W przypadku, gdy użytkownik wprowadzi jako wejście wektor tekstowy (if(is.character(x))) to działanie funkcji zostanie przerwane i pojawi się odpowiedni komunikat błędu.

minus_1("kot")
#> Error in minus_1("kot"): Argument `x` musi być zmienną numeryczną, a nie znakową.

Jeżeli jako argument x zostanie podany wektor logiczny (else if(is.logical(x))) to pojawi się komunikat ostrzeżenia, ale dalsze obliczanie zostanie wykonane. W tym przypadku wartość TRUE zostanie najpierw zamieniona na jeden a FALSE na zero, następnie od tych wartości zostanie odjęte jeden, a na końcu zostaną one zamienione na wartości bezwzględne.

minus_1(c(TRUE, FALSE))
#> Warning in minus_1(c(TRUE, FALSE)): Argument `x`
#> jest zmienną logiczną. Czy nie chcesz użyć zmiennej
#> numerycznej?
#> [1] 0 1

Po wprowadzeniu wartości numerycznych do funkcji minus_1() pojawi się tekst wiadomości, po którym nastąpi wyliczenie kodu abs(x - 1).

minus_1(c(1, 0, 6, -6))
#> Wow. Argument `x` jest oczekiwaną zmienną numeryczną.
#> [1] 0 1 5 7

Złożone funkcje opierają się o inne istniejące funkcje. W powyższym przykładzie, minus_1() używał, między innymi funkcji - do odejmowania czy abs do wyliczania wartości bezwzględnej. Czasami spodziewamy się, że wartość wprowadzona przez użytkownika może spowodować wystąpienie wewnętrznego błędu i jednocześnie wiemy jak to naprawić. W takich sytuacjach przydaje się funkcja tryCatch().

R pozwala na ignorowanie wystąpienia błędu używając funkcji try(), ignorowanie ostrzeżeń z suppressWarnings() oraz wiadomości z suppressMessages().

tryCatch() stara się uruchomić jakiś wskazany kod, a w przypadku pojawienia się błędu wykonuje alternatywne obliczenia. Można to zobaczyć na poniższym przykładzie, gdzie najpierw sprawdzona zostałaby linia kod do uruchomienia i dopiero gdyby ona skutkowała błędem zostałaby uruchomiona linia wykonaj kod w przypadku wystąpienia błędu.

tryCatch(
  error = function(e) {
    wykonaj kod w przypadku wystąpienia błędu
  },
  kod do uruchomienia 
)

Działanie tryCatch w praktyce jest pokazane w funkcji log_safe(). Stara się ona wyliczyć logarytm naturalny (log()) z wartości argumentu x, a w przypadku gdyby napotkała błąd zwróci ona wartość NA.

log_safe = function(x){
  tryCatch(
  error = function(e) {
    NA
  },
  log(x)
  )
}

Sprawdźmy jej zachowanie na dwóch przykładach. W pierwszym oryginalna funkcja log() jak i nowa log_safe() otrzymają poprawne dane wejściowe - wektor numeryczny.

log(10)
#> [1] 2.3
log_safe(10)
#> [1] 2.3

W tym przypadku obie zwracają dokładnie taki sam wynik. Jeżeli jednak jako dane wejściowe wprowadzimy wektor znakowy to oryginalna funkcja zwróci błąd, a nasza funkcja jedynie wartość NA.

log("abecadło")
#> Error in log("abecadło"): non-numeric argument to mathematical function
log_safe("abecadło")
#> [1] NA
Dodatkowo istnieje funkcja withCallingHandlers(), która jest używana w przypadku działania na ostrzeżeniach.

10.3 Programowanie obiektowe

Programowanie obiektowe (ang. object-oriented programming, OOP) to jeden z najpopularniejszych paradygmatów programowania (sekcja 1.2). Polega on na definiowaniu obiektów danej klasy posiadających pewną określoną strukturę oraz zachowania.

R pozwala również na stosowanie paradygmatu obiektowego. Co więcej, w tym języku istnieje kilka różnych systemów programowania obiektowego, między innymi S3, S4 czy R6. Każdy z nich charakteryzuje inny sposób tworzenia obiektów czy ich zachowań. W tym rozdziale skupimy się na najczęściej używanego systemu S3.

Dwa najważniejsze elementy tego systemu to klasy i metody. Klasa obejmuje obiekty o podobnej strukturze, które posiadają specjalną informację o nazwie klasy. Metoda natomiast to sposób zachowania funkcji w przypadku napotkania obiektu danej klasy. Przykład metody był pokazany w sekcji 7.5, gdzie funkcja mean() zachowywała się różnie w zależności od klasy danych wejściowych.

10.3.1 Klasy

Poniżej stworzono nową macierz x, która składa się z dwóch kolumn i dwóch wierszy oraz wartości 0, 0, 2 i 3. Ma ona na celu reprezentowanie figury geometrycznej - prostokąta. W najprostszej postaci prostokąt można opisać używając czterech współrzędnych - najmniejszej wartości położenia na osi x (np., 0), najmniejszej wartości położenia na osi y (np., 0), największej wartości położenia na osi x (np., 2), oraz największej wartości położenia na osi y (np., 3).

x = matrix(c(0, 0, 2, 3), ncol = 2)
x
#>      [,1] [,2]
#> [1,]    0    2
#> [2,]    0    3

Do sprawdzenia klasy obiektu w systemie S3 służy funkcja class().

class(x)
#> [1] "matrix" "array"

W efekcie upewniamy się, że klasa naszego obiektu x to matrix, array. System S3 pozwala na prostą zmianę lub dodanie nazwy klasy używając funkcji structure().

y = structure(x, class = "prostokat")

Wynikiem działania tej funkcji z argumentem class = "prostokat" jest nowy obiekt y. W momencie, gdy sprawdzimy jego klasę, okaże się że nie jest to już matrix, array ale prostokat.

class(y)
#> [1] "prostokat"

10.3.2 Metody

Posiadamy teraz nową klasę, prostokat, ale nie posiadamy do niej żadnych metod. Metoda w systemie S3 to funkcja, która działa w różny sposób w zależności od klasy danych wejściowych. Możliwe jest zarówno dodanie nowej metody do istniejącej funkcji, jak i stworzenie nowej funkcji.

W tym wypadku interesuje nas możliwość policzenia powierzchni. Możemy do tego celu stworzyć nową funkcję w systemie S3 o nazwie powierzchnia. Pierwszym krokiem musi być określenie, że nasza funkcja ma być oparta o system S3 używając poniższej formy.

powierzchnia = function(x) {
  UseMethod("powierzchnia")
}

Drugim krokiem jest zdefiniowanie funkcji do wyliczania powierzchni prostokąta. Określa ona najpierw długości boków a i b, a następnie wymnaża je w celu wyliczenia powierzchni.

powierzchnia.prostokat = function(x){
  a = x[1, 2] - x[1, 1] #wyliczenie długości boku a
  b = x[2, 2] - x[2, 1] #wyliczenie długości boku b
  a * b                 #wyliczenie powierzchni prostokąta
}

Nazwa powyższej funkcji wygląda jakby składała się z dwóch słów oddzielonych kropką - powierzchnia.prostokat. W rzeczywistości jednak nazwa funkcji to tylko powierzchnia, a kropka sugeruje że kolejny po niej wyraz to klasa obiektu jaki przyjmie funkcja. Jest to, innymi słowy, definicja metody. Nowa funkcja powierzchnia zadziała w powyższy sposób tylko w wypadku otrzymania jako dane wejściowe obiektu klasy prostokat.

Sprawdźmy to na dwóch przykładach - obiektu y (klasa prostokat) i x (klasa matrix).

y
#>      [,1] [,2]
#> [1,]    0    2
#> [2,]    0    3
#> attr(,"class")
#> [1] "prostokat"
powierzchnia(y)
#> [1] 6

W przypadku, gdy nasz obiekt wejściowy jest klasy prostokat to funkcja jest wykonywana zgodnie z metodą powierzchnia.prostokat(),

x
#>      [,1] [,2]
#> [1,]    0    2
#> [2,]    0    3
powierzchnia(x)
#> Error in UseMethod("powierzchnia"): no applicable method for 'powierzchnia' applied to an object of class "c('matrix', 'array', 'double', 'numeric')"

Natomiast, gdy obiekt wejściowy będzie innej klasy to pojawi się komunikat błędu sugerujący, że nie istnieje metoda dla tej klasy pozwalająca na otrzymanie wyniku.

Dodatkowo, oprócz tworzenia metod dla każdej klasy oddzielnie możliwe jest stworzenie metody domyślnej poprzez nazwafunkcji.default. W przypadku, gdy dla obiektu wejściowego nie istnieje metoda to wówczas wykonywana jest metoda domyślna (default). Poniżej dodano metodę domyślną - w przypadku, gdy dla wejściowego obiektu nie ma metody to pojawi się poniższy komunikat błędu.

powierzchnia.default = function(x) {
  stop("Funkcja `powierzchnia` ma wsparcie tylko dla obiektów o klasie `prostokąt`")
}

Sprawdźmy działanie domyślnej metody podając macierz jako obiekt wejściowy.

x
#>      [,1] [,2]
#> [1,]    0    2
#> [2,]    0    3
powierzchnia(x)
#> Error in powierzchnia.default(x): Funkcja `powierzchnia` ma wsparcie tylko dla obiektów o klasie `prostokąt`

10.3.3 Konstruktory

Trudno oczekiwać od użytkownika, że bez żadnych pomyłek stworzy obiekt klasy, który wymyśliliśmy, a następnie użyje funkcji structure(), aby dodać odpowiednią nazwę klasy. Dlatego też ważnym elementem jest stworzenie konstruktora - funkcji, której celem jest zbudowanie poprawnego obiektu naszej klasy, a w przypadku podania złych argumentów wejściowych poinformowanie użytkownika co jest nie tak.

Poniżej znajduje się konstruktor o nazwie nowy_prostokąt(). Przyjmuje on wartości czterech współrzędnych, a następnie wykonuje szereg sprawdzeń ich poprawności:

  • Czy wszystkie argumenty są typu numerycznego?
  • Czy każdy argument ma tylko jeden element?
  • Czy minimalna wartość współrzędnej x jest mniejsza od maksymalnej?
  • Czy minimalna wartość współrzędnej y jest mniejsza od maksymalnej?

Po tych sprawdzeniach następuje zbudowanie nowej macierzy oraz dodanie nazwy klasy.

nowy_prostokat = function(xmin, ymin, xmax, ymax){
  vals = c(xmin, ymin, xmax, ymax)
  if (!(is.numeric(vals))){
    stop("Wszystkie argumenty muszą być typu numerycznego")
  }
  if (!all(c(length(xmin), length(ymin), length(xmax), length(ymax)) == 1)){
    stop("Każdy z argumentów może przyjmować tylko jedną wartość")
  }
  x_range = vals[3] - vals[1]
  if (x_range <= 0){
    stop("`xmax` musi przyjmować wartość większą niż `xmin`")
  }
  y_range = vals[4] - vals[2]
  if (y_range <= 0) {
    stop("`ymax` musi przyjmować wartość większą niż `ymin`")
  }
  x = matrix(vals, ncol = 2)
  structure(x, class = "prostokat")
}

Sprawdźmy działanie tego konstruktora na dwóch przypadkach. W pierwszym podajmy poprawne, sprawdzone wcześniej wartości.

nowy_p = nowy_prostokat(0, 0, 2, 3)
nowy_p
#>      [,1] [,2]
#> [1,]    0    2
#> [2,]    0    3
#> attr(,"class")
#> [1] "prostokat"

Konstruktor nowy_prostokat() działa bez problemu, zwracając nowy obiekt nowy_p o klasie prostokat. Warto od razu zobaczyć, czy ten obiekt zadziała poprawnie w funkcji powierzchnia().

powierzchnia(nowy_p)
#> [1] 6

W przypadku, gdy do konstruktora zostaną podane niepoprawne wartości wejściowe pojawi się odpowiedni komunikat błędu.

nowy_p2 = nowy_prostokat(7, 0, 6, 0)
#> Error in nowy_prostokat(7, 0, 6, 0): `xmax` musi przyjmować wartość większą niż `xmin`

10.4 Zadania

  1. Bez pisania kodu, zaprojektuj API zbioru funkcji R pozwalających na tworzenie podstawowych obiektów reprezentujących podstawowe figury (np. kwadrat, prostokąt, koło, trójkąt, itd.) oraz wyliczania na ich podstawie podstawowych miar (np. obwód, pole powierzchni, itd.). Nowe API powinno obejmować nazwy funkcji, nazwy ich argumentów, istnienie lub brak domyślnych wartości argumentów, klasy obiektów wejściowych i wyjściowych z tych funkcji, itd.

  2. Stwórz nową klasę obiektów w R reprezentujących trójkąty. Nazwij tą nową klasę "trojkat". W jaki sposób trójkąty będą reprezentowane w tej nowej klasie? (Podpowiedź: w zależności od podjętej decyzji nowa klasa może być oparta o wektory, macierze lub ramki danych.)

  3. Dodaj konstruktor pozwalający innym użytkownikom na tworzenie obiektów klasy "trojkat". Zastanów się jakie powinny być wartości argumentów wejściowych i napisz wewnątrz konstruktora odpowiednie sprawdzenia używając komunikatów błędów, ostrzeżeń czy też wiadomości.

  4. Stwórz metodę pozwalającą na wyliczanie powierzchni trójkąta.

  5. Stwórz metodę pozwalającą na określanie współrzędnych centroidu trójkąta.

Bibliografia

Hesselbarth, Maximillian H. K., Marco Sciaini, Jakub Nowosad, and Sebastian Hanss. 2020. Landscapemetrics: Landscape Metrics for Categorical Map Patterns. https://CRAN.R-project.org/package=landscapemetrics.