No Exceptions - problemy z biblioteką standardową i biblioteki zewnętrzne
To jest trzecia część cyklu o walce z wyjątkami w Scali. Zobacz część pierwszą i drugą.
Problemy bibliotek standardowych
Biblioteka standardowa Scali nie jest idealna. Ma swoje problemy. Ale Scala jako język programowania nie jest tu wyjątkiem.
Biblioteki standardowe często mają błędy i niedoskonałości. Twórcy języków mają mało chęci by łamać istniejące API. Przykładem może być funkcja gets z języka C o sygnaturze:
char *gets(char *str);
Funkcja nie sprawdza, czy jest miejsce do zapisu w tablicy str
. Z tego powodu funkcja ta jest niebezpieczna. A mogłaby mieć zmienioną sygnaturę na:
char *gets(char *str, int size);
Co rozwiązałoby problem. Jednak po wielu latach twórcy standardu C zamiast poprawić funkcję gets
stwierdzili, że wolą ją usunąć.
Co to ma wspólnego ze Scalą? Otóż niektórzy programiści, wiedząc że są marne szanse na naprawę błędów projektowych ze standardowej biblioteki Scali, napisali własne, poprawione wersje.
Jedną z takich poprawionych wersji biblioteki standardowej Scali jest biblioteka Scalaz. Biblioteka ta jest inspirowana językiem Haskell. Język Haskell jest w pełni funkcyjny. Ale co ważniejsze jest też, według mnie, w pełni funkcjonalnym językiem. Co to oznacza? Że posiada te wszystkie rzeczy, które są potrzebne, żeby używać go jako język produkcyjny w korpoaplikacjach, takie jak statyczne typowanie, polimorfizm i obsługa błędów. Jednak w języku Haskell obsługa błędów jest zrobiona za pomocą konstrukcji takich jak monady i aplikatywy.
Disjunction vs Either
Monada Disjunction
z biblioteki Scalaz jest lepszą wersję Either
ze standardowej biblioteki Scali. I tak jak Either
posiada dwie podklasy:
/** A left disjunction
*
* Often used to represent the failure case of a result
*/
final case class -\/[+A](a: A) extends (A \/ Nothing)
/** A right disjunction
*
* Often used to represent the success case of a result
*/
final case class \/-[+B](b: B) extends (Nothing \/ B)
Powyższy kod może wydawać się dziwny, do momentu gdy nie uświadomimy sobie, że klasa Disjunction
nawet nie istnieje w kodzie biblioteki Scalaz. Disjunction
jest tylko aliasem na klasę \/
(czytane jako “Wściekły Zając”, ale ja wymawiam to jako “Hail Hydra”):
type Disjunction[+A, +B] = \/[A, B]
val Disjunction = \/
No dobrze, po tej informacji dalej jest to dziwne. Osobiście nie przepadam za nazwami klas składającymi się z samych symboli jak \/
, \/-
i -\/
.
W czym monada Disjunction
jest lepsza od monady Either
? W przypadku monady Either
rozróżnienie między wartościami right
i left
było tylko konwencją. Powodowało to, że musieliśmy explicit
wybierać, którą wartość chcemy użyć. W przypadku monady Disjunction
wartość poprawna jest używana domyślnie. Jest to przydatne zwłaszcza w konstrukcji [For Comprehensions]
Co do kodu skryptu nie ma tu Rocket Science. Użycie monady Disjunction
jest prostsze od Either
ponieważ domyślnie dla metod map
i flatMap
jest używana strona z poprawnym wynikiem.
abstract class DisjunctionAPIState(data: UrlsWithThrowableList)(implicit d: Domain) extends AbstractAPIState(data) {
override type HP = SourcePageDisjunction
def nextData(set: SourcePageDisjunctionSet): UrlsWithThrowableList = {
val partitioned = set.partition(_.isRight)
val newWrappedUrls: WrappedUrlSet = partitioned._1
.flatMap(DisjunctionAPIState.sourcePageDisjunctionToWrappedUrlSet)
val newThrowableList: ThrowableList = partitioned._2.toList
.flatMap(_.swap.toOption.toList)
val newUrls = NewUrls(newWrappedUrls)
data.next(newUrls, newThrowableList)
}
}
object DisjunctionAPIState {
val sourcePageDisjunctionToWrappedUrlSet: SourcePageDisjunction => WrappedUrlSet =
_.map(_.getWrappedUrlSet).toOption.toSet.flatten
}
Validation vs Disjunction
Klasa Validation
także posiada dwie podklasy, ale w tym przypadku nie mamy dziwnych symboli, tylko intuicyjne nazwy:
final case class Success[A](a: A) extends Validation[Nothing, A]
final case class Failure[E](e: E) extends Validation[E, Nothing]
Jaka jest różnica pomiędzy Disjunction
a Validation
? Z technicznego punktu widzenia Disjunction
jest [monadą], a Validation
jest tylko aplikatywem. Tak, też mi to za dużo nie mówi. Z logicznego punktu widzenia Disjunction
przerywa działanie na pierwszym błędzie, a Validation
używa się, gdy chce się kumulować błędy np. podczas walidacji danych. Stąd nazwa klasy.
abstract class ValidationAPIState(data: UrlsWithThrowableList)(implicit d: Domain) extends AbstractAPIState(data) {
override type HP = SourcePageValidation
def nextData(set: SourcePageValidationSet): UrlsWithThrowableList = {
val partitioned = set.partition(_.isSuccess)
val newWrappedUrls: WrappedUrlSet = partitioned._1
.flatMap(ValidationAPIState.sourcePageValidationToWrappedUrlSet)
val newThrowableList: ThrowableList = partitioned._2.toList
.flatMap(_.swap.toOption.toList)
val newUrls = NewUrls(newWrappedUrls)
data.next(newUrls, newThrowableList)
}
}
object ValidationAPIState {
val sourcePageValidationToWrappedUrlSet: SourcePageValidation => WrappedUrlSet =
_.map(_.getWrappedUrlSet).toOption.toSet.flatten
}
Niestety w kodzie tym nie widać przewagi Validation
nad Disjunction
, ponieważ agregację błędów oraz poprawnych wyników robię ręcznie w kodzie. Myślę że po prostu nie lubię monady \/
z powodu jej niewymawialnej nazwy.
Task vs Future
Co jest złego w Future
? Po pierwsze nie jest monadą, jest tylko FlatMappable - z tym oczywiście można żyć. Większość swojego zawodowe życia piszę kod bez monad i on działa i zarabia na siebie i ja zarabiam na siebie. Więc bez monad na produkcji można żyć. O wiele większym problemem jest to, że parametr implicit ec: ExecutionContext
jest potrzebny do tworzenia i przekształcania konstrukcji Future
.
Tego problemu nie ma w przypadku monady Task
.
Monadzie Task
można łatwo przekazać fragment kodu do wykonania w przyszłości:
object SourcePageTaskFromInternalUrl {
def apply(internalUrl: InternalUrl): SourcePageTask =
Task { internalUrl |> SourcePageValidationFromInternalUrl.apply }
}
Także wykonywanie obliczeń i przekształceń na monadzie Task
nie sprawia problemów:
object TaskState {
def apply(domain: String): ParallelStateTask = fromDomain(new Domain(domain)) |> TaskState.run
def fromDomain(implicit d: Domain): TaskState = new TaskState(UrlsWithThrowableList.fromDomain)
def run(state: TaskState): Task[ValidationAPIState] =
if (state.isEmptyNextInternalUrls) Task.now(state) else state.nextMonad.flatMap(run)
}
class TaskState(refsWithThrowable: UrlsWithThrowableList)(implicit d: Domain) extends ValidationAPIState(refsWithThrowable) {
override type NextState = TaskState
override def nextState(data: UrlsWithThrowableList): TaskState = new TaskState(data)
def nextMonad: Task[TaskState] = {
val set: Set[Task[SourcePageValidation]] = refsWithThrowable.nextUrls
.map(SourcePageTaskFromInternalUrl.apply)
val monad: Task[SourcePageValidationSet] = Task
.gatherUnordered(set.toSeq)
.map(_.toSet)
monad.map(newState)
}
}
A kod nie posiada nadmiarowych parametrów implicit
jest o wiele czystszy i łatwiejszy w utrzymaniu.
Podsumowanie
Język Scala posiada rozbudowaną bibliotekę standardową, ale nie wszystko wewnątrz niej jest idealne. Dlatego dobrze jest znać alternatywy. Zwłaszcza, gdy chodzi o tak trudny temat jak przejrzysta obsługa błędów i programowanie równoległe. Warto więc na bieżąco obserwować zmiany w ekosystemie Scali.
Kod jest oczywiście dostępny na Githubie.
Zostaw komentarz