No Exceptions - problemy z wyjątkami i biblioteka standardowa
To jest druga część cyklu o walce z wyjątkami w Scali. Zobacz część pierwszą.
Try vs Either
Try
nie jest jedynym sposobem w bibliotece standardowej Scali na radzenie sobie z wyjątkami w kodzie. W ostatnim przykładzie poprzedniej części użyliśmy monady Try
. Ogólnie Try
nie jest zalecane w nowym kodzie. Chociażby dlatego, że nie możemy zamiast Throwable
używać bardziej specyficznego błędu. W naszym przypadku UrlException
.
Rozwiązaniem jest monada Either
. Monada Either
podobnie jak monada Try
posiada dwie wartości. Jednak w jego przypadku nazywają się one right
i left
:
right
jest prawa - czyli poprawnyleft
jest lewa - czyli niepoprawny
Chociaż w przypadku monady Either
ze standardowej biblioteki Scali jest to głównie tylko konwencja.
object SourcePageEitherFromInternalUrl extends InternalUrlTo[SourcePageEither] {
override def apply(internalUrl: InternalUrl): SourcePageEither = applyWithThrowable(internalUrl).left.map(internalUrl.toException)
private def applyWithThrowable(internalUrl: InternalUrl) = nonFatalCatch either { internalUrl |> SourcePageFromInternalUrl.apply }
}
type SourcePageEither = Either[UrlException, SourcePage]
Metoda nonFatalCatch
z obiektu singletonowego scala.util.control.Exception
jest ładnym wrapperem na obiekt singletonowy NonFatal
użyty w poprzedniej części cyklu.
Jaka jest wyższość monady Either
nad monadą Try
? W monadzie Try
możemy sparametryzować tylko warość poprawną. W monadzie Eiher
- także błąd. Ogólnie to szkoda, że monada Try
nie jest zdefiniowany jako:
type Try[A] = Either[Throwable, A]
Bo taką funkcję właśnie pełni w kodzie
Reszta kodu po przeróbkach:
object EitherState extends AbstractNextStateObject {
override protected def fromDomain(implicit d: Domain): AbstractNextState = new EitherState(UrlsWithThrowableList.fromDomain)
}
class EitherState(data: UrlsWithThrowableList)(implicit d: Domain) extends EitherAPIState(data) with AbstractFunctionState {
override protected def nextState(data: UrlsWithThrowableList): AbstractNextState = new EitherState(data)
override protected def impureFunction: HPFromInternalUrl = SourcePageEitherFromInternalUrl.apply
}
abstract class EitherAPIState(data: UrlsWithThrowableList)(implicit d: Domain) extends AbstractAPIState(data) {
override protected type HP = SourcePageEither
protected def nextData(set: SourcePageEitherSet): UrlsWithThrowableList = {
val partitioned = set.partition(_.isRight)
val newWrappedUrls: WrappedUrlSet = partitioned._1
.flatMap(EitherAPIState.sourcePageEitherToWrappedUrlSet)
val newThrowableList: ThrowableList = partitioned._2.toList
.flatMap(_.left.toOption.toList)
val newUrls = NewUrls(newWrappedUrls)
data.next(newUrls, newThrowableList)
}
}
object EitherAPIState {
val sourcePageEitherToWrappedUrlSet: SourcePageEither => WrappedUrlSet =
_.right.map(_.getWrappedUrlSet).right.toOption.toSet.flatten
}
Podobnie jak w przypadku Try
dzielimy listę pobranych źródeł na elementy dobre lub nie, jednak teraz są to elementy right
lub left
.
Future
Poprzednie wersje skryptu miały jedną bardzo ważną wadę. Interakcje ze światem zewnętrznym były wykonywane po kolej (sekwencyjnie). Pobieranie źródła strony jest akcją angażującą urządzenia wejścia-wyjścia (IO) jest to akcja powolna. Procesor w tym czasie czeka i nic nie robi.
Można to rozwiązać za pomocą (prawie) monady, czyli FlatMappable Future
. Jej nazwa pochodzi od tego że wynik dostaniemy w przyszłości, a w międzyczasie procesor może wykonywać inne rzeczy jak:
- budować kolejne monady
Future
- przeliczać wartości które już otrzymał z monady
Future
Konstrukcje podobne to Future
w różnych bibliotekach i językach są zwane także zadaniami (ang. Task
), obietnicami (ang. Promise
) lub po prostu IO.
object SourcePageFutureFromInternalUrl {
def apply(internalUrl: InternalUrl)(implicit ec: ExecutionContext): SourcePageFuture =
Future { internalUrl |> SourcePageEitherFromInternalUrl.apply }
}
implicit ec: ExecutionContext
jest potrzebny do tworzenia i przekształcania monady Future
.
Short Future
Reszta kodu po dostosowaniu do monady Future
:
object EitherBeginState {
def apply(domain: String)(implicit ec: ExecutionContext): AbstractNextState = fromDomain(new Domain(domain)) |> AbstractNextState.run
private def fromDomain(domain: Domain)(implicit ec: ExecutionContext): AbstractNextState = fromDomainAllImplicit(domain, ec)
private def fromDomainAllImplicit(implicit d: Domain, ec: ExecutionContext): AbstractNextState =
new EitherBeginState(UrlsWithThrowableList.fromDomain)
}
class EitherBeginState(data: UrlsWithThrowableList)(implicit d: Domain, ec: ExecutionContext) extends EitherAPIState(data) with AbstractNewSetState {
override protected def nextState(data: UrlsWithThrowableList): AbstractNextState = new EitherBeginState(data)
override protected def newSet: SourcePageEitherSet = {
val set: Set[Future[SourcePageEither]] = nextUrls
.map(SourcePageFutureFromInternalUrl.apply)
val future: Future[SourcePageEitherSet] = Future.sequence(set)
Await.result(future, 1.minute)
}
Metoda Future.sequence
trawersuje zbiór, czyli zbiór monad Future
zamienia na jedną monadę Future
zawierającą zbiór. Następnie metoda Await.result(future, 1.minute)
wypakowywuje zawartość monady, czyli oczekuje blokująco maksymalnie jedną minutę aż wszystkie zadania zostaną zakończone.
Long Future
Ponieważ wypakowywanie monady Future
jest operacją blokującą powinniśmy odwlec ten moment maksymalnie w czasie. Niestety tutaj bez zmiany interfejsu można przesunąć tylko jedno mapowanie wyniku:
object EitherEndState {
def apply(domain: String)(implicit ec: ExecutionContext): AbstractNextState = fromDomain(new Domain(domain)) |> AbstractNextState.run
private def fromDomain(domain: Domain)(implicit ec: ExecutionContext): AbstractNextState = fromDomainAllImplicit(domain, ec)
private def fromDomainAllImplicit(implicit d: Domain, ec: ExecutionContext): AbstractNextState =
new EitherEndState(UrlsWithThrowableList.fromDomain)
}
class EitherEndState(data: UrlsWithThrowableList)(implicit d: Domain, ec: ExecutionContext) extends EitherAPIState(data) with AbstractNextState {
override protected def nextState(data: UrlsWithThrowableList): AbstractNextState = new EitherEndState(data)
override def next: AbstractNextState = {
val set: Set[Future[SourcePageEither]] = nextUrls
.map(SourcePageFutureFromInternalUrl.apply)
val monad: Future[NextState] = Future
.sequence(set)
.map(newState)
Await.result(monad, 1.minute)
}
}
Future forever
Jak już pisałem monadę Future
należy wypakowywać jak najpóźniej, a w idealnym świecie nigdy nie powinniśmy wypakowywać monady Future
.
Niestety w tym celu musimy porzucić wywołania ogonowe i zadowolić się zwykłą rekurencją
object FutureState {
def apply(domain: String)(implicit ec: ExecutionContext): ParallelStateFuture = fromDomain(new Domain(domain)) |> run
private def fromDomain(domain: Domain)(implicit ec: ExecutionContext): FutureState = fromDomainAllImplicit(domain, ec)
private def fromDomainAllImplicit(implicit d: Domain, ec: ExecutionContext): FutureState =
new FutureState(UrlsWithThrowableList.fromDomain)
private def run(state: FutureState)(implicit executor: ExecutionContext): Future[EitherAPIState] =
if (state.isEmptyNextInternalUrls) Future.successful(state) else state.nextMonad.flatMap(run)
}
class FutureState(data: UrlsWithThrowableList)(implicit d: Domain, ec: ExecutionContext) extends EitherAPIState(data) {
override protected def nextState(data: UrlsWithThrowableList): NextState = new FutureState(data)
override type NextState = FutureState
def nextMonad: Future[FutureState] = {
val set: Set[Future[SourcePageEither]] = nextUrls
.map(SourcePageFutureFromInternalUrl.apply)
val monad: Future[SourcePageEitherSet] = Future
.sequence(set)
monad.map(newState)
}
}
Podsumowanie
Monady pozwalają nie tylko rozwiązywać problem wyjątków w kodzie, ale także ułatwiają programowanie asynchroniczne oraz równoległe.
Skąd pewność, że cokolwiek z tego co tu opisałem ma sens i jest przydatne w codziennej pracy programisty, a nie jest tylko wymysłem twórców języka Haskell? Ponieważ nowoczesny i wysokowydajny natywny język programowania Rust, zwany przez twórców językiem systemowym, nie posiada wyjątków. Dla błędów naprawialnych posiada klasę Result
będącą odpowiednikiem klasy Either
ze Scali. A dla błędów nie naprawialnych posiada makro panic!
będące odpowiednikiem klasy Error
, ale niemożliwej do złapania. Każde makro panic!
zabija wątek w którym zostało zastosowane. Mimo tego Rust jest szybki jak C i C++.
Monad Option
, Either
, Try
oraz Future
można używać także w języku programowania Java za pomocą biblioteki vavr.
Kod jest oczywiście dostępny na Githubie.
Zostaw komentarz