Wdrożenie systemu przechowywania podobnego do bazy danych z plikami CSV

Teraz, gdy mamy już zdefiniowany interfejs Database, zaimplementujemy system podobny do bazy danych, który używa plików CSV do przechowywania informacji zamiast rzeczywistej bazy danych. Po pierwsze, upewniamy się, że przenosimy zależności klasy CSVFiles za pomocą funkcji source(), aby przenieść pliki, które mają potrzebne definicje. W szczególności wprowadzamy klasy Exchange i User  (które zostaną zdefiniowane później), a także interfejs Database. Definiujemy również stałą DIR z katalogiem, który będzie zawierał pliki CSV z naszymi danymi systemu. Rzeczywista klasa CSVFile jest definiowana przy użyciu standardowych metod R6 przedstawionych wcześniej. Zauważ, że dziedziczy on z klasy Database i zapewnia przesłonięcia dla każdej metody w interfejsie Database, tak jak powinno. Zwróć również uwagę, że wewnątrz konstruktora, czyli funkcji initilize, wywołujemy funkcję initialize_csv_files() i wysyłając listę table_names, którą otrzymujemy podczas inicjalizacji. Ponieważ chcieliśmy, aby czytelnik zapoznał się z pełną definicją klasy w pojedynczym fragmencie kodu, a nie kawałek po kawałku, zawarliśmy tutaj wszystko i wyjaśnimy w kolejnych akapitach. Jest trochę za długi, ponieważ zawiera logikę dla wszystkich metod w interfejsie Database, ale na wysokim poziomie jest to nic innego jak implementacja wspomnianego interfejsu. Ponieważ chcieliśmy, aby czytelnik zapoznał się z pełną definicją klasy w pojedynczym fragmencie kodu, a nie kawałek po kawałku, zawarliśmy to wszystko tutaj i wyjaśnimy w kolejnych akapitach. Jest trochę za długi, ponieważ zawiera logikę dla wszystkich metod w interfejsie Database, ale na wysokim poziomie to nic innego jak implementacja wspomnianego interfejsu:

source(„../assets/exchange/exchange.R”. hdir = TRUE)

source(„../users/user.R”, chdir = TRUE)

source(„.database.R”)

DIR <- „./csv-files/”

CSVFiles <-R6Class (

„CSVFiles”,

inherit = Database,

public = list (

initialize – function(table_names) {

super$set_table_names(table_names)

initialize_csv_files(table_names)

},

read_exchanges = function() {

return(list (Exhange$new(„CoinMarketCap{)))

},

read_users = function(storage) {

data <- private$read_csv(„users”)

return(lapply(data$email, user_constructor, storage))

},

read_wallets = function(email) {

data <- private$read_csv(„wallets)

wallets <- NULL

if (nrow(data) >= 1) {

for ( i in 1:nrow(data)) {

if (data[i, „email”] == email) {

wallets <- c(wallets, list(Wallet$new(

data[i, „email”],

data[i, „symbol”],

data[i, „address”],

data[i, „note”])

))

}

}

} else { wallets <- list() }

return(wallets)

},

read_all_wallets = function() {

data <- private$read_csv(„wallets”)

wallets <- NULL

if (nrow(data) >= 1 ) {

for (i in 1 L nrow(data) ) {

wallets <- c(wallets, list(Wallet$new (

data[i, „email”],

data[i, „symbol”],

data[i, „address”],

data[i, „note”])

))

}

}else {wallets <- list() }

return(wallets)

},

write_user = function(user) {

data <- private$read_csv(„users”)

new_row <- as.data.frame(data$3(user))

print(new_row)

if (private$user_does_not_exist(user, data)) {

data <- rbind(data , new_row)

}

private$write_csv(„users”, data)

},

write_wallets = function(wallets) {

data <- private$read_csv(„wallets”)

for (wallet in wallets)

new_row <- as.data.frame(wallet$data() )

print(new_row)

if (private$wallet_does_not_ exist(wallet, data)) {

data <- rbind(data, new_row)

}

}

private$write_csv(„wallets”, data)

},

write_assets = function(assets) {

data <- private$read_csv(„assets”)

for (asset in assets) {

new_row <- as.data.frame(dataS4(asset))

print(new_row)

data <-rbind(data, new_row)

}

private$write_csv(„assets”, data)

}.

write_markets = function(markets) {

data <- private$read_csv(„markets”)

for (marjet in markets) {

new_row <- as.data.frame(market$data() )

print(new_row)

data <- rbind(data, new_row)

}

private$write)csv(„markets”, data)

}

),

private = list(

read_csv = function(table_name) {

return(read.sv (

private$file(table_name),

stringsAsFactors = FALSE))

},

write_csv = function(table_name, data) {

write.csv(data,

file = private$file(table_name),

row.names = FALSE)

},

file = function(table_name) {

return(paste (

DIR, suoer$get_table_names()[[table_name]],

„.csv”, sep = „ „))\

},

user_does_not_exist = function(user, data) {

if (dataS3(user) [[„email]] %in% data$email) {

return(FALSE)

}

return(TRUE)

},

wallet_does_not_exist = function(wallet, data) {

current_addresses <-data[

data$email == wallet$get_email()&

data$symbol == wallet$get_symbol(),

„address”

]

if (wallet$get_address() %in% current_addresses_ {

return(FALSE)

}

return(TRUE)

}

)

)

Teraz pokrótce wyjaśnimy mechanikę implementacji każdej metody. Zacznijmy od read_exchanges() Teoretycznie metoda ta powinna zajrzeć do przechowywanych danych i pobrać listę central zarejestrowanych w systemie, utworzyć instancję dla każdej z nich i odesłać ją. W praktyce nie jest to jednak konieczne, ponieważ samo zakodowanie giełdy CoinMarketCap jest wystarczające do naszych celów. Jak widać, to wszystko, co robi metoda: zwraca listę z pojedynczym Exchange w środku, czyli taką dla CoinMarketCap. Metoda read_users() odczytuje dane z pliku „user” metodą prywatną read_csv() zdefiniowaną poniżej i zwraca listę utworzoną za pomocą funkcji lapply(), która pobiera każdy e-mail w danych i wysyła go przez user_construtor() wraz z obiektem storage odebrany jako parametr do utworzenia instancji  User , które są następnie odsyłane w wyniku wywołania metody. Metoda read_wallets() jest nieco bardziej złożona. Otrzymuje email jako parametr, odczytuje plik „wallets” i tworzy listę instancji Wallet. Ponieważ sprawdzamy, czy konkretna obserwacja w danych zawiera email równą żądanej, możemy po prostu użyć funkcji lapply() (moglibyśmy, gdybyśmy utworzyli oddzielną funkcję  która zawiera to, sprawdź, ale decydujemy się nie iść tą trasą). Należy również zauważyć, że funkcja będzie próbować iterować po wierszu w ramce danych tylko wtedy, gdy ramka danych zawiera co najmniej jeden wiersz. To sprawdzenie zostało wprowadzone po tym, jak odkryliśmy, że gdy nie było go, gdy mieliśmy puste pliki, otrzymywaliśmy błąd, ponieważ pętla for była faktycznie wykonywana, nawet jeśli nie było żadnych wierszy. Jeśli okaże się, że email jest takie samo, jak żądane, dodajemy nową instancję Wallet do listy wallets i zwracamy ją. Jeśli nie ma portfeli do utworzenia, obiekt wallets jest przekształcany w pustą listę. Metoda read_all_wallets() działa w ten sam sposób, ale pomija kontrolę email. Metoda write_user() otrzymuje instancję User, odczytuje data dla pliku „users”, tworzy ramkę danych z danymi wyodrębnionymi za pomocą funkcji dataS3 wywołaną z obiektu User, wypisuje ją na konsoli w celach informacyjnych, a jeśli nie zostanie znaleziona w bieżących danych, zostanie do niej dodana. Na koniec dane są zapisywane z powrotem do pliku „users”. Faktyczne sprawdzenie jest wykonywane metodą prywatną user_does_not_exist(), która po prostu sprawdza, czy e-mail User  nie jest zawarty w kolumnie email w danych, jak widać we wspomnianej wcześniej definicji. Metoda write_wallets() otrzymuje listę  instancji Wallet, odczytuje plik  „wallets” i dodaje go dla każdego wallet, którego nie znaleziono już w danych. Koncepcyjnie jest podobna do metody write_user(), a sprawdzenie jest wykonywane przez prywatną metodę wallet_does_not_exist() , która odbiera instancję Wallet i używa zawartych w niej  email i symbol, aby uzyskać addresses, które są już skojarzone z takimi kombinacjami (przypomnij sobie, że jeden użytkownik może mieć wiele portfeli dla tego samego rodzaju aktywów i byłby rozróżniany tylko na podstawie adresów portfeli). Jeśli okaże się, że address w instancji Wallet już istnieje w takim podzbiorze, nie jest dodawane.

Metody write_assets() i write_markets() są podobne i należy je łatwo zrozumieć. Różnica polega na tym, że na razie nie zawierają one żadnych sprawdzeń i zapisują odpowiednio obiekty S4 i R6. Możesz to stwierdzić po tym, że wywołują metodę datS4() i składnię, aby uzyskać dane Market, będąc market$data(). Prywatne metody używane do odczytu i zapisu plików CSV powinny być łatwe do zrozumienia. Pamiętaj tylko, że rzeczywiste nazwy plików pochodzą z metody prywatnej file(), która używa table_names zawartej w nadklasie (Database) przez wywołanie metody pobierającej super$get_table_names() i pobranie odpowiedniej nazwy pliku skojarzonego z daną table_name. Lista table_name zostanie później zdefiniowana w scentralizowanym pliku ustawień, ale jest po prostu listą zawierającą ciąg znaków dla każdej nazwy tabeli (w przypadku CSVFiles nazwa pliku) skojarzone z każdym typem obiektu, który ma być przechowywany.

Teraz przechodzimy do omawiania funkcji initialize_csv_files() . Ta funkcja otrzymuje listę table_names i upewnia się, że katalog DIR istnieje z funkją dir.create(). Parametr showWarnings = FALSE ma na celu uniknięcie ostrzeżeń kiedy katalog już istnieje na dysku. Następnie dla każdego elementu na liście table_names utworzy odpowiedni filename i sprawdzi, czy istnieje na dysku z funkji  file.exist() . Jeśli tak się nie stanie, rozpocznie tworzenie pustej ramki danych pliku  odpowiedniego typ i zapisz go na dysku:

initialize_csv_files <-funtion(table_names) {

dir.create(DI, showWarnings = FALSE)

for (table in table_names) {

filename <- paste(DIR, table, „.csv”, sep = „ „)

if (!file.exist(filena,e) ) {

data <- empty_dataframe(table)

write.csv(data, file = filename, row.naes =FALSE)

}

}

}

Różne typy pustych ramek danych jest wybieranych funkcją empty_dataframe(), która otrzymuje określoną nazwę tabeli w parametrze table i zwraca odpowiadającą jej pustą ramkę danych. Zauważ, że testy zakładają, że słowa dla różnych obiektów, które należy zapisać, znajdują się w nazwach tabel zdefiniowanych w scentralizowanym pliku ustawień i że nazwy dwóch różnych abstrakcji nie pojawiają się razem w jednej nazwie tabeli:

empty_datafrmae <- function(table) {

of (grepl(„assets”, table)) {

return(empty_assets() )

}

else if (grepl(„markets”, table)) {

return(empty_markets() )

} else if (grepl(”users”, table)) {

return(empty_users() )

} else id (grepl(„wallets”, table)) {

return(empty_wallets() )

} else {

stop(„Unknown table name”)

}

}

Rzeczywiste puste ramki danych są tworzone przez funkcje empty_assets() empty_markets(), ,empty_users() i empty_wallets(). Każda z nich zawiera specyfikację danych, które mają znajdować się w takich plikach. W szczególności każda obserwacja w danych zasobu powinna mieć adres e-mail, znacznik czasu, nazwę, symbol, sumę i adres. Oczekuje się, że każda obserwacja w danych rynkowych będzie miała znacznik czasu, nazwę, symbol, rangę, cenę w BTC i cenę w USD. Ranking to kolejność kryptowalut na podstawie kwoty wolumenu transakcji w ciągu ostatnich 24 godzin. Dane użytkowników powinny zawierać tylko e-maile. Wreszcie, oczekuje się, że dane portfela będą zawierać adres e-mail, symbol, adres i notatkę. Notatka jest notatką, którą użytkownik może określić, aby rozpoznawać różne portfele od siebie, szczególnie jeśli są one używane dla tego samego rodzaju kryptowaluty. Może jeden portfel Bitcoin jest długoterminowy, a drugi krótkoterminowy; wtedy te informacje można by określić w polu uwagi. Spójrzmy na następujący kod:

empty_assets <- function() {

return(data.frame (

email = character(),

timestamp = character(),

name = character(),

symbol = character(),

total = numeric(),

address = character()

))

}

empty_markets <- function() {

return(data.frame (

timestamp = character(),

name = character(),

symbol = character(),

rank = numeric(),

price_btc = numeric()

price_usd = numeric()

))

}

empty_users <- function() {

return(data.frame (

email = character(),

))

}

empty_walletst <- function() {

return(data.frame (

email = character(),

symbol = character(),

address = character(),

note  = character()

))

}

Implementacja naszej warstwy pamięci masowej z klasami R6

Do tego momentu złożoność naszego kodu nie była większa niż ta pokazana w przykładach dla kolorów, prostokątów i kwadratów. W tym momencie kod będzie nieco bardziej złożony, ponieważ mamy do czynienia z bardziej złożonymi abstrakcjami i interakcjami między nimi, ale jesteśmy gotowi sprostać wyzwaniu za pomocą tego, co wiemy do tej pory.

Przekazywanie dostępnego zachowania za pomocą interfejsu bazy danych

Zaczniemy od zdefiniowania interfejsu dla baz danych w klasie Database. Ta klasa nigdy nie ma być tworzona sama. Jego celem jest po prostu zapewnienie definicji interfejsu, której należy przestrzegać w przypadku określonych implementacji baz danych, takich jak implementacja, którą opracujemy, oraz implementacja CSVFIles, aby komunikować się z dowolną bazą danych . Zaletą zdefiniowania takiego interfejsu jest to, że zapewnia on wspólny język dla tych obiektów do komunikowania się ze sobą i dostarcza programiście odniesienia do tego, co należy zrobić i jak należy nazwać metody, aby działały poza pudełko z resztą systemu. W Pythonie byłyby one nazywane Abstract Base Classes . R nie ma formalnego zastosowania dla tych klas abstrakcyjnych, ale mimo to możemy sami zaimplementować je w ten sposób. Jak widać, nasz interfejs R6 Database określa, jakie metody powinny być dostępne publicznie zaimplementowane oraz fakt, że nazwy tabel używane w bazie danych powinny pozostać prywatne. Dodajemy ten atrybut listy table_names zamiast zakodować na stałe nazwy tabel bezpośrednio w naszych klasach, ponieważ chcemy mieć możliwość ich łatwej zmiany w pliku ustawień (więcej o tym później) i chcą łatwo je zmienić dla różnych środowisk, w których będziemy śpiewać ten kod (głównie środowiska produkcyjne i programistyczne). Metody publiczne to metody pobierające i ustawiające dla table_namse oraz grupy metod używanych do odczytu i zapisu danych, które zawierają przedrostek określający, do czego są używane. Powinno być jasne, czego oczekują i co zwracają. W szczególności metoda read_exchanges nie otrzymuje żadnych parametrów i powinna zwrócić listę obiektów Exchange  (która zostanie zdefiniowana później). read_users() zwraca listę obiektów  User, (które również zostaną zdefiniowane później) i wymaga wystąpienia Storage, które zostanie przypisane każdemu użytkownikowi stworzone tak, aby mogły czytać i zapisywać dane. Metoda read_wallets() odbiera ciąg wiadomości e-mail i zwraca listę obiektów Wallet (również do zdefiniowania później). Metoda read_all_wallets() ma być używana tylko przez admins systemu i zwróci listę wszystkich portfeli w systemie, nie tylko portfele należące do konkretnego użytkownika. Po stronie zapisu metoda write_user() otrzymuje obiekt User i zapisuje go na dysku, a jak widać po symbolach {}, nie oczekuje się, że zwróci cokolwiek. Podobnie, inne metody zapisu odbierają instancję klasy i zapisują ją na dysku. Potrzebujemy jednej metody zapisu dla każdego typu zajęć, ponieważ będą one wymagały różnych zabiegów podczas zapisywania:

Database <- R6Class (

„Database”,

public = list (

set_table_names = function(table_names) {

private$table_names <- tables_names

},

get_table_names = funtion() ,

return(pruvate$table)names)

},

read_exchanges = function() list(),

read_users = function(storage) list(),

read_wallets = function(email) list()

read_all_wallets = function() list()

read_analysisi_assetes = funtion(email) list()

write_user = function(user) {},

write_wallet = function(wallet) {},

write_assets = funtion(assets) {},

write_markets = function(markets) {}

).

private = list(table_names = list() )

)

Wdrażanie aktywów kryptowalutowych przy użyciu klas S4

Teraz zaimplementujemy naszą następną abstrakcję z najmniejszą liczbą zależności, Asset. Zaimplementujemy to za pomocą S4 i zależy to tylko od TimeStamp. Definiujemy jego klasę przy użyciu standardowych metod przedstawionych wcześniej, a jej atrybuty obejmują email w celu zidentyfikowania użytkownika, do którego należy zasób,  timestamp w celu zidentyfikowania zasobu w określonym momencie,  name aby wiedzieć, z jakim zasobem mamy do czynienia, symbol identyfikuje typ zasobu w naszym systemie, total aby odnotować, jaką część zasobu posiada użytkownik, oraz  address aby zidentyfikować portfel, do którego należy zasób (użytkownik może mieć kilka portfeli dla tego samego rodzaju zasobu):

setClass (

Class = „Asset”,

representation = representation (

email = „character”,

timestamp = „character”,

name = „character”,

symbol = „character”,

total = „numeric”,

address = „character”

)

)

Zauważ, że zamiast używać klasy S3 dla TimeStamp w atrybucie timestamp, decydujemy się po prostu zadeklarować ją jako character i zarządzać nasze tłumaczenie między typami. Pozwala nam to zachować kontrolę nad procesem transformacji i uniknąć nieoczekiwanego zachowania języka R podczas mieszania modeli obiektów. Następnie udostępniamy funkcje ustawiające do zmiany atrybutów email i timestamp, ponieważ będziemy ich potrzebować podczas pobierania danych zasobów i zapisywania ich na dysku. Jest to jedna z tych decyzji projektowych, które ewoluowały wraz z rozwojem systemu, a nie przewidzieliśmy tego ,potrzebowałby tych metod; zostały dodane w późniejszym czasie:

setGeneric(„email<-„ , function(self,value) standardGeneri(„email<-„))

setReplaceMethod(„email”, „Asset”, function(self, value) {

self@email <- value

return(self)

})

setGeneric(„timestamp<-„, function(self, value)

standardGeneric(„timestamo<-„))

setReplaceMethod(„timestamp”, „Asset”, function(self,value) {

self@timestamp <- value

return(self)

})

Teraz zaimplementujemy metodę data$4, która pozwoli nam pobrać dane, które wymagają zapisania z naszych obiektów S4. Zwróć uwagę, że zastosowaliśmy tę samą technikę, co wcześniej, aby odróżnić metody dataS4 od dataS3 i uniknąć wszelkich pułapek z R:

setGeneric(„dataS4”, function(self) standardFGeneric(„dataS4”))

setMethod(„dataS4”, „Asset”, function(self) {

return(list (

email = self@email,

timestamp – self@timestamp,

name = self@name,

symbol = self@symbol,

total = self@total,

address – self@address

))

})

Implementacja AnalysisAsset zostanie przeniesiona do następnego rozdziału, w którym przedstawiamy typy analiz, które chcemy przeprowadzić z tymi danymi.

Zaczynając od prostych znaczników czasu przy użyciu klas S3

Zaczynamy od zaprogramowania klasy, która nie ma zewnętrznych zależności, TimeStamp. Użyjemy tej klasy do wskazania dat i godzin razem w jednym ciągu w formacie YYYY-MM-DD-HH_mm, gdzie MM oznacza miesiąc, a mm minuty. Jak widać, w przypadku jednego z tych ciągów masz informacje o czasie i dacie, które zostaną zapisane z danymi, które pobieramy z szeregów czasowych do analizy .Nasza klasa TimeStamp zostanie zaimplementowana przy użyciu S3. Jak widać, dołączamy pakiet lubridate, aby wykonać za nas ciężką pracę podczas przekształcania dat, i zapewniamy konstruktor, który sprawdza, czy przekazywany ciąg jest prawidłowym znacznikiem czasu:

library(lubridate)

timestamp_constructor <- function(timestamp = now.TimeStamp() ) {

class(timestamp) <- „TimeStamp”

if  (valid(timestamp) ) { return(timestamp) }

stop(„Ivalid timestamp (format should be : ‘YYYY-MM-DD-HH-mm’)”

)

alidacja jest wykonywana przez funkcję valid.TimeStamp()  która upewnia się, że w ciągu są tylko myślniki (-) i cyfry, a liczba liczb oddzielonych tymi myślnikami wynosi pięć (rok, miesiąc, dzień, godzina i minuty) i że ciąg może być analizowany przez funkcję strptime(), która służy do tworzenia obiektów daty z obiektów ciągu (jeśli nie jest  NA, to można go przeanalizować):

valid.TimeStamp <- function(timestamp) {

if (gsub(„-„, „”, gsub(„[[:digit:]]”, „”, timestamp)) != „”) {

}

if (length(strsplit(timestamp, „-„) [[1]]) != 5) {

return(FALSE)

}

if (is.na(strptime(timestamp,%Y-%m-%d-%H-%M”))) {

return(FALSE)

}

return(FALSE)

}

return(TRUE)

}

valid <- function(object) {

UseMethod(„valid”, object)

}

Udostępniamy również funkcję now.TimeStamp(), której zadaniem jest tworzenie znacznika czasu dla aktualnego czasu i daty. Robi to poprzez wywołanie funkcji Sys.time() i parsowanie wynikowego obiektu w formacie, który określiliśmy wcześniej:

now.TimeStamp <- function() {

timestamp <- format(Sys.time(), %Y-%m-%d-%H-%M”)

class(timestamp) <- „TImeStamp”

return(timestamp)

}

Następnie przedstawiamy sposób przekształcania natywnych obiektów czasu w nasze własne obiekty TimeStamp. Po prostu używamy funkcji format() tak jak poprzednio. Wprowadzamy również mechanizm przekształcania naszych własnych TimeStamp obiektów w natywne obiekty czasu:

time_to_timestamp.TimeStamp <- function(time) {

timestamp <- format(time, „%Y-%m-%d-%H-%M”)

class(timestamp) <- „TimeStamp”

return(timetostamp)

)

timetostamp_to_time.TimeStamp <- functionI(timestamp) {

returdn(strptime(timestam, „„%Y-%m-%d-%H-%M”))

}

Funkcja subtract.TimeStamp() będzie ważna podczas pobierania danych, ponieważ możemy chcieć, aby wszystkie aktywa zawierające datę zaczynającą się od poprzedniego punktu w czasie pobranego z danego TimeStamp. Funkcja otrzymuje dwa parametry: bieżący TimeStamp i przedział czasu, o jakim ma wskazywać wynikowe TimeStamp. W zależności od wybranego interwału, który może wynosić godzinę, dzień, tydzień, miesiąc lub rok, oznaczony odpowiednio 1h, 1d, 1w, 1m, będziemy wywoływać różne funkcje z pakietu lubridate (hours(), days(), weeks(), months(), years() ), które otrzymują, ile jednostek o określonej nazwie ma zostać użytych w operacji. Są to łatwe sposoby dodawania lub odejmowania czasu w R. Zauważ, że jeśli minie nieznany przedział czasu, zgłosimy błąd. Niektórzy uważają, że należy unikać dodawania tych przypadków else z jakimś błędem, ponieważ wskazuje to na niepewne programowanie w tym sensie, że powinieneś wiedzieć, jakie opcje należy przekazać do funkcji, i nigdy nie powinieneś tak naprawdę skończyć w else i woleliby upewnić się, że ich kod działa, używając testów jednostkowych, zamiast sprawdzać wewnętrznie z warunkami. Jednak używamy go, aby zilustrować jego użycie i ponieważ nie używamy testów jednostkowych do udowodnienia poprawności w naszym kodzie. Uważam również, że nigdy nie można być zbyt ostrożnym w tego typu sytuacjach, a znalazłem się w sytuacjach, w których dodanie tej prostej innej gałęzi pomogło mi zdiagnozować błąd o wiele łatwiej:

subtract.TimeStamp <- function(timestamp, interval) {

time <- timestamp_to_time.TimeStamp(timestamp)

if (interval == „1h”) {

time <- time – hours(1)

} else  if (interval == „1d”) {

time <- time – days(1)

} else if (interval ==”1w”) {

time <- time – weeks(1)

} else if (interval == „1m”) {

time  <- time – months(1)

} ele if (intervals = „1y”) {

time <- time – years(1)

} else {

stop(„Unknown interval”)

}

timestamp <- time_to_timestamp.TimeStamp(time)

return(timestamp)

}

subtract <- function (object, interval) {

UseMethod(„subtract”, object)

}

Na koniec dodajemy wygodną funkcję one_year_ago.TmeStamp(), która po prostu utworzy bieżącą TimeStamp i odejmie jedną rok od tego. Jest to jedna z tych funkcji, które po prostu ewoluują wraz z rozwojem systemu, ponieważ zauważyłem, że potrzebuję tej funkcji w kółko, więc mogę po prostu ułatwić sobie życie w ten sposób:

one_year_ago>TimeStamp <- function() {

return(subtrat(now.TimeStamp(), „1y”))

}

Teraz klasa jest gotowa. Zapraszam do zabawy, aby upewnić się, że działa zgodnie z oczekiwaniami. Dzięki temu, co widzieliśmy do tej pory w tym rozdziale, powinieneś być w stanie tworzyć instancje i używać różnych metod, które stworzyliśmy. Powinieneś także spróbować go złamać, aby znaleźć słabe punkty i ewentualnie poprawić niezawodność implementacji.

Architektura naszego systemu kryptowalut

Teraz, gdy podstawy programowania obiektowego w języku R zostały już zilustrowane, weźmiemy te zasady i zastosujemy je do przykładu, z którym będziemy pracować do końca książki. Zbudujemy system do śledzenia kryptowalut za pomocą programowania obiektowego. Jeśli nie znasz kryptowalut, przeczytaj na początku krótkie wprowadzenie. Projekt i implementacja, które zobaczysz w tym przykładzie, ewoluowały w różnych iteracjach i tygodniach. W rzeczywistości jest to część podstawowego systemu, aby zaoferować pojedynczy punkt prawdy użytkownikom zarządzającym różnorodnymi zestaw kryptowalut (chociaż nie został zaimplementowany w R), więc nie myśl, że powinieneś być w stanie wymyślić taki projekt od razu (chociaż wiele osób z pewnością jest w stanie, ale przez większość czasu systemy zorientowane ewoluują w nieprzewidziany sposób). Jak to ujął Grady Booch: „Złożony system, który działa, niezmiennie wyewoluował z prostego systemu, który działał. Złożony system zaprojektowany od podstaw nigdy nie działa i nie można go naprawić, aby działał. Musisz zacząć od nowa, począwszy od działającego prostego systemu ”. Weźmy się za to. Jak być może wiesz, kryptowaluty można przechowywać na kontach giełdowych oraz w portfelach. Ponieważ przechowywanie kryptowalut przechowywanych na kontach giełdowych jest bardzo złym pomysłem (jest to ryzykowne, a użytkownik może skończyć się utratą swoich aktywów, co wielokrotnie się zdarzało), skupimy się tylko na przypadkach, w których kryptowaluty są przechowywane w portfelach. Zasadniczo staramy się uzyskać źródło prawdziwych danych o tym, ile posiadamy kryptowalut i ile są one warte, zarówno w danym momencie. Aby wdrożyć system, pierwszą rzeczą, jaką musimy zrobić, jest zidentyfikowanie podstawowych abstrakcji, którymi są w naszym przypadku: użytkownicy, aktywa, portfele i giełdy. Dla uproszczenia na tej liście uwzględnimy również rynki i bazy danych. Będziemy używać terminu aktywa zamiast kryptowaluty ze względu na fakt, że niektóre z nich nie są technicznie walutami, ale możesz swobodnie wymieniać te terminy bez zamieszania. W naszym przypadku załóżmy, że od początku zdecydowaliśmy, że nawet jeśli będziemy czytać dane z jednego źródła, możemy chcieć zapisywać dane w wielu bazach danych, gdy je otrzymamy. Niektóre z tych baz danych mogą być lokalne, a inne mogą być zdalne. Jednak nie chcemy, aby każdy element systemu wiedział, że w użyciu jest wiele baz danych, ponieważ tak naprawdę nie potrzebują one tych informacji do działania. Dlatego wprowadzimy kolejną abstrakcję, a mianowicie pamięć masową, która będzie zawierała te informacje wewnątrz i która będzie wyglądać jak pojedyncza baza danych dla innych obiektów, które muszą odczytywać lub zapisywać dane, i zajmie się za nie szczegółami. Uwzględniamy tę abstrakcję na naszej liście abstrakcji podstawowych i ta lista jest w tym momencie kompletna:

Teraz musimy zdefiniować, jak ta główna abstrakcja będzie oddziaływać między sobą. Wiemy, że użytkownik może mieć kilka portfeli w swoim posiadaniu, a te z kolei mają w sobie aktywa. Zwróć uwagę, że oddzieliliśmy abstrakcje aktywów i portfeli, ponieważ niektóre portfele mogą zawierać więcej niż aktywa wewnątrz (na przykład portfele Ethereum mogą zawierać różne rodzaje tokenów). Ponieważ przewidujemy ten przypadek, upewnimy się, że możemy sobie z nim odpowiednio poradzić, oddzielając te pojęcia. Użytkownicy chcą również mieć możliwość przechowywania własnych informacji, a także informacji o swoich zasobach. Aby to zrobić, otrzymają obiekt pamięci i wywołają metody na tym obiekcie, dla których publiczny interfejs będzie dobrze zdefiniowany. Abstrakcja pamięci będzie z kolei zawierać jedną bazę danych do odczytu, ale może zawierać kilka baz danych do zapisu, jak wspomnieliśmy wcześniej. Przechowuje te obiekty bazy danych wewnątrz i wysyła do nich komunikaty niezbędne do wykonania operacji odczytu i zapisu w imieniu obiektów, które go używają. Wreszcie, tak jak portfele zawierają aktywa, giełdy zawierają rynki. Różnica polega na tym, że aktywa identyfikują jeden rodzaj kryptowaluty, podczas gdy rynki używają dokładnie dwóch kryptowalut do zdefiniowania. Dlatego możemy mieć rynek do wymiany USD na BTC (zapisywany jako USD / BTC), co oznacza, że ​​ludzie mogą używać dolarów amerykańskich do kupowania / sprzedawania Bitcoinów. Inne rynki mogą być BTC / LTC lub LTC / USD (gdzie LTC oznacza Litecoin). Liczba, którą będziemy pobierać z portfeli, to pojedyncza liczba określająca, ile konkretnego zasobu posiadamy. Liczba, którą będziemy pobierać z rynków, to stosunek reprezentujący cenę lub to, o ile jednego zasobu żąda się, aby otrzymać jedną jednostkę drugiego. Stosunek BTC / USD wynoszący 8000 oznacza, że ​​aby otrzymać jeden Bitcoin, oczekuje się, że otrzymasz 8000 USD (jest to cena w momencie pisania tego akapitu). Podobnie, LTC / BTC na poziomie 0,0086 oznacza, że ​​oczekuje się, że otrzymasz 0,0086 Bitcoina, aby otrzymać jeden litecoin. Teraz, gdy te relacje są mniej więcej zdefiniowane, będziemy musieli wprowadzić do gry więcej abstrakcji, aby napisać kod, który sprawi, że nasz system stanie się rzeczywistością. Na przykład wiemy, że nasza abstrakcja portfela będzie wykorzystywać podobny mechanizm do pobierania danych z różnych łańcuchów bloków. Można to umieścić w żądaniu portfela. Co więcej, ten requester portfela zostanie zaimplementowany na różne sposoby i musi zostać wybrany w czasie wykonywania zgodnie z konkretnym portfelem, z którym mamy do czynienia. Zamiast tworzyć inny portfel dla każdego typu zasobu i programować mechanizm pobierania danych z łańcucha bloków w każdym z nich, wyabstrahujemy to i tworzymy fabrykę requesterów portfela, która da naszemu portfelowi abstrakcji specyficzny typ żądającego portfela, którego potrzebuje dla danego zasobu. Podobnie nasza abstrakcja bazy danych może być zaimplementowana dla różnego rodzaju baz danych, więc oddzielamy interfejs od implementacji i wprowadzamy fabrykę, która wybierze konkretną implementację, z której będziemy korzystać. W naszym przypadku będziemy zapisywać dane do plików CSV, ale równie łatwo moglibyśmy korzystać z bazy danych .W podobny sposób nasz kod pobierze na razie dane z CoinMarketCap , ale może się to zmienić później. CoinMarketCap nie jest giełdą jako taką; jest raczej agregatorem danych cenowych. Ponieważ jednak w przyszłości możemy chcieć pracować z danymi cenowymi z różnych giełd (takich jak Bittrex lub Bitfinex), zapewnimy taką abstrakcję, a ponieważ nie przewiduje się potrzeby traktowania CoinMarketCap inaczej niż giełdy, po prostu uwzględnimy to w tej abstrakcji. Na marginesie, obraz architektury nie ma być diagramem UML. UML to skrót od Unified Modeling Language, narzędzia powszechnie używanego do przekazywania pomysłów związanych z systemami obiektowymi. Jest to narzędzie, którego zdecydowanie powinieneś się nauczyć, jeśli planujesz poważnie programować obiektowo. Należy również pamiętać, że nie zaimplementujemy obiektów wyświetlanych w kolorze szarym, a mianowicie requestera Bitfinex, requestera Bittrex, MySQL i requestera Ether. Są one pozostawione jako ćwiczenie dla użytkownika. Nasz system będzie bez nich w pełni funkcjonalny. W tym momencie wydaje się, że mamy bardzo dobre pojęcie o abstrakcjach, które chcemy zbudować, i interakcjach, które będą występować między tymi abstrakcjami, więc czas rozpocząć programowanie. Podczas gdy będziemy przeglądać kod systemu, nie zatrzymamy się, aby wyjaśnić koncepcje, które omówiliśmy wcześniej; wyjaśnimy tylko funkcje, które mogą nie być oczywiste. Na koniec powinieneś wiedzieć, że każda zaimplementowana przez nas abstrakcja trafi do własnego pliku. Jest to standardowa praktyka i pomaga szybko znaleźć miejsce, w którym należy zaimplementować lub zmodyfikować kod. Wśród tych plików istnieje przejrzysta i intuicyjna hierarchia.

Rzeczywisty kod ma następującą strukturę (pliki kończą się rozszerzeniem .R, a katalogi symbolem /):

cryptocurrencies/

assets/

analysis-asset.R

asset.R

exchanges/

exchanges.R

market.R

requesters/

coinmarket cap-requester.r

exchange-requester-fatory.R

exchange-requester.R

wallets/

requesters/

btc-requester.R

ltc-requester.R

wallet-requester-factory.R

wallet-requester.R

wallet.R

batch/

create-user-data.R

update-assets.R

update-markets.R

settings.R

storage/

cvs-files.R

database-factory.R

database.R

storage.R

users/

admin.R

user.R

utilities/

requester.R

time-stamp.

Aktywne wiązania

Aktywne powiązania wyglądają jak pola, ale przy każdym dostępie wywołują funkcję. Są zawsze widoczne publicznie i są podobne do właściwości Pythona. Gdybyśmy chcieli zaimplementować metodę color() jako aktywne powiązanie, moglibyśmy użyć następującego kodu. Jak widać, możesz pobrać lub ustawić atrybut color bez użycia jawnego wywołania metody (zwróć uwagę na brakujące nawiasy):

R6Rectangle <- R6Class (

„R6Retangle”,

public – list (

),

private = list (

),

active = list (

color =  function(new_color) {

if (missing(new_color)) {

return(private$own_color)

} else {

private4own_color <- new_color

}

}

}

}

R6_rectangle <- R6Rectangle$new(2,3, „blue”

R6_rectangle$color

#> [1] “blue”

R6_rectangle$color <- black

R6_rectangle$color

#> [1] “black”

Jak widać, gdy aktywne powiązanie jest używane jako metoda pobierająca (w celu pobrania wartości), wywołuje metodę bez przekazywania wartości. Gdy jest dostępny jako ustawiacz (w celu zmiany atrybutu), wywołuje metodę przekazującą wartość do przypisania. Nie można użyć aktywnego powiązania jako ustawiającego, jeśli funkcja nie przyjmuje argumentów.

Finalizatory

Czasami warto uruchomić funkcję, gdy obiekt jest odśmiecany. Jeśli nie jesteś zaznajomiony z wyrzucaniem elementów bezużytecznych, możesz o tym myśleć jako o sposobie uwolnienia nieużywanej pamięci, gdy inne obiekty w środowisku nie odwołują się już do obiektu. Przydatnym przypadkiem w przypadku tej funkcji jest upewnienie się, że plik lub połączenie z bazą danych zostaje zamknięty, zanim obiekt zostanie wyrzucony do pamięci. Aby to zrobić, możesz zdefiniować metodę finalize(), która będzie wywoływana bez argumentów, gdy obiekt zostanie wyczyszczony. Aby przetestować tę funkcję, możesz po prostu dodać finalizator w następujący sposób do niektórych swoich obiektów i zobaczyć, kiedy pojawi się komunikat „Finalizer called” w konsoli:

A <- R6Class(„A”, publi = list (

finalize = function() {

print(„Finalizer called.”)

}

))

Finalizatory zostaną również wywołane po wyjściu

Dziedziczenie

Dziedziczenie jest również bardziej znane podczas pracy z modelem obiektowym R6. W tym przypadku możesz po prostu dodać parametr inherit do wywołania funkcji R6Class() i możesz wywołać metodę initialize dla nadklasy przez używając super$initialize(). W tym przypadku używamy tej techniki, aby zapewnić użytkownikowi bardziej intuicyjny interfejs konstruktora: pojedyncza wartość długości w przypadku kwadratu, zamiast konieczności dwukrotnego powtarzania tej samej wartości, co może być podatne na sprzeczne z intuicją zachowanie jeśli nie jest zaznaczone. Możemy również nadpisać metodę print(), tak jak normalnie dodalibyśmy inną metodę:

R6Square <- R6Class(

„R6Square:,

inherit = R6Recatngle,

public = list (

initialize = function(a, color) {

super$initialize(a, a, color)

},

print = function() {

print(paste (

self$color() , „square:”,

private$a, „x”, private$b, „==”, self$area()

))

}

)

)

Jak widać, w tym przypadku otrzymujemy listę klas, które zawierają bieżącą klasę R6Square, a także klasy, z których dziedziczy ten obiekt, R6Rectangle i R6. Ponieważ użyliśmy przesłonięcia dla metody print(), możemy użyć wspólnej składni print(object) zamiast metody ad-hoc object$print() składnia dostarczona przez R6:

R6_square <- R^Square$new(4, „red”)

class(R6_square)

#> [1] “R6Square” “R6Rectangle” “R6”

print(R6_square)

#> [1] “czerwony kwadrat: 4 x 4 == 16”

Hermetyzacja i zmienność

Ponieważ umieściliśmy a,b i own_color na liście private w definicji klasy, pozostają one prywatne i w ten sposób w R6 wymusza się hermetyzację. Jak widać, nie pozwolono nam przypisać go bezpośrednio do atrybutu a zgodnie z oczekiwaniami, ponieważ został umieszczony na liście private.  Daje to pewność, że nie możemy mieć atrybutów lub metod oznaczonych jako prywatne bezpośrednio z zewnątrz obiektu i zapobiega podejmowaniu złych decyzji podczas kodowania. To ogromna zaleta modelu R6.

Hermetyzację w R6 osiąga się poprzez otoczenie.

Zmienność uzyskuje się za pomocą seterów (metod służących do zmiany atrybutu klasy). Zauważ, że w tym przypadku nie musimy ponownie przypisywać wynikowego obiektu, tak jak robimy to z S3. Stan jest faktycznie zapisywany w środowisku obiektu i można go zmienić; zatem R6 ma zmienność:

R6_rectangle$a

#> NULL

R6_rectangle$own_print(_

#> [1] “niebieski prostokąt: 2 x 3 == 6”

R6_rectangle$a <- 1

#> Błąd w R6_rectangle $ a <- 1:

nie może dodawać powiązań do zablokowanego środowiska

R6_rectangle$own_print()

#> [1] “niebieski prostokąt: 2 x 3 == 6”

R6_recta

#> [1] “czarny prostokąt: 2 x 3 == 6”

Klasy, konstruktory i kompozycja

Klasy w R6 tworzy się funkcją R6Class(), a my przekazujemy nazwę klasy oraz listy obiektów publicznych i prywatnych. Te obiekty mogą być atrybutami lub metodami. Jak widać, zbudowanie definicji klasy w R6 daje znacznie czystszy kod, który jest złożony w jednej definicji zamiast procesu krok po kroku używanego w S3 i S4. To podejście bardziej przypomina to, co można znaleźć w innych popularnych językach. Możesz określić, jak powinien zachowywać się konstruktor, używając metody initialize. Ta konkretna metoda zostanie wywołana podczas tworzenia instancji klasy. Istnieją dwie ważne różnice między naszymi nazwami w poniższej definicji i tego, czego używaliśmy w przykładach S3 i S4. W tym przypadku nazywamy metodę print own_print() i  właściwość property. Powodem tego pierwszego jest to, że R byłoby mylone między metodą color() a atrybutem color. Aby uniknąć błędów, możemy zmienić nazwę jednego z nich i aby nasz publiczny interfejs był taki sam, zdecyduj się w tym przypadku zmienić atrybut prywatny. Powodem dla own_print() będzie wyjaśnione z wyprzedzeniem:

library(R6)

R6retangle <- R6Class (

„R6Rectangle”,

public = list(

initialize = function(a,b, olor) {

private$a <- a

private$b <- b

private4own_color <- color

},

area = function() {

private$a * private$b

},

color = fi=unction() {

private$own_color

},

set_color = function(new_color) {

private$own_color <- new_color

},

own_print = function() {

print(paste (

self$color() , „rectangle:” ,

private$a , „x”, private$b , „==” , selfarea()

))

}

),

private = list (

a = NULL,

b = NULL,

own_color = NULL

)

)

Aby utworzyć instancję klasy, wywołujemy metodę new() w obiekcie klasy. Możemy przekazać niektóre parametry, a jeśli to zrobimy, zostaną one użyte przez funkcję initialize zdefiniowaną dla klasy.

Jak widać, jeśli użyjemy print() na obiekcie R6_rectangle, zobaczymy ładny wynik informujący nas, które metody i atrybuty są również publiczne i prywatne jako dodatkowe informacje na ich temat, np. fakt, że domyślna metoda clone() (używana do tworzenia kopii obiektu R6) jest ustawiona na płytkie kopiowanie zamiast kopiowania głębokiego. Nie będziemy wchodzić w szczegóły, czym są te koncepcje, ale zainteresowanych czytelników zachęca się do przyjrzenia się mechanice przekazywania przez odniesienie i mechaniki przekazywania wartości. Gdybyśmy zdefiniowali metodę print() w naszej klasie, to print(R6_rectangle) użyłby tej funkcji domyślnie. Zauważ, że składnia różniłaby się od bezpośredniego wywoływania metody przez wykonanie polecenia takiego jak R6_rectangle$print(), ale R jest wystarczająco inteligentny, aby wiedzieć, że jeśli zdefiniujesz metodę print() w swojej klasie, prawdopodobnie dlatego, że chcesz jej użyć podczas używania funkcji print() na obiekcie. Jeśli tak nie jest, powinieneś zmienić nazwę swojej niestandardowej funkcji drukowania, tak jak robimy w przypadku nazwy metody own_print:

R6_rectangle <- R6Rectangle$new(2,3, „blue”)

class*+(R6_rectangle)

#> [1] “R6Rectangle” “R6”

print(R6_rectangle)

#> <R6Rectangle>

#> Publiczne:

#> area: function ()

#> clone: ​​function (deep = FALSE)

#> kolor: funkcja ()

#> inicjalizacja: funkcja (a, b, kolor)

#> own_print: funkcja ()

#> set_color: function (nowy_kolor)

#> Prywatne:

#> a: 2

#> b: 3

#> own_color: niebieski

Jak widać na wyjściu, w przypadku klas R6 mamy dwie klasy zamiast jednej. Mamy klasę, którą sami zdefiniowaliśmy, a także klasę ogólną R6dodaną do obiektu.