Spotkanie z TaskCompletionSource – Cz. 1 I promise I will call back

Programowanie asynchroniczne w C# stało całkiem znośne od kiedy język ten posiada słowa kluczowe async i await. Rozwiązanie to tak udało się tak dobrze, że zaczyna pojawiać się w innych językach. VB.Net podobno już je ma (może któryś z czytelników już go używał i mógłby podzielić się swoimi doświadczeniami?), architekci projektujący C++ i Javascript także nad usilnie pracują na wdrożeniem podobnych mechanizmów.

Wielu z nas, programistów, jest jednak do dzisiaj zmuszonych pisać asynchronicznie tak jak (nie)potrafi i tak środowisko im na to (nie)pozwala. Nawet w C# sytuacja nie wygląda kolorowo, klasa Task umożliwia użycie jej w tandemie z async i await zaledwie od 4 lat. Żaden kod napisany przez pierwsze 11 lat istnienia .Net nie miał szans ich użycia. Kod napisany później także niekoniecznie z niego korzysta.

Myślę, że w takiej sytuacji adaptacja starego API na własną rękę może być opłacalna, dlatego postanowiłem popełnić serię artykułów pokazujących jak można się za to zabrać.
Na szczęście Microsoft szybko zauważył problem adaptacji starego kodu do nowego wzorca, czego efektem jest TaskCompletionSource, który umożliwia przetworzenie czegokolwiek na Task’a.

W pierwszej części chciałbym pokazać adaptację API opartego na kolbakach (ang.callbacks) i obiecankach (ang. Promises).

Wiele angielskich słów nie ma idealnego tłumaczenia, „callback” nie jest tu wyjątkiem. Programiści rozumieją je jako „wywołanie zwrotne”, jednak nikt tak nie mówi, na co dzień używam pięknego słowa „kolbak” i tak będę robił dalej. Dla niewtajemniczonych kolbak opisuje rozwiązanie, w którym metoda jako jeden z argumentów przyjmuje inną metodę, która jest wywoływana po zakończeniu operacji.

public void GetNextInt(Action<int> callback)
{
    /* Magic in here */
    callback(123);
}

„We will call back to you” w dosłownym tłumaczeniu oznacza „Oddzwonimy do ciebie”. Często to słyszymy, a jak często otrzymujemy telefon gdy coś pójdzie nie tak? I jak się czujemy gdy ktoś długo nie oddzwania? Podobnie czuje się kod i programista, który go napisał, gdy wywołuje metodę z kolbakiem, a ten nigdy nie następuje. Dlatego dobrą praktyką jest przekazanie kodu błędu albo wyjątku jako dodatkowego parametru kolbaka.

public void GetNextInt(Action<int, Exception> callback)
{
    try
    {
        /*Magic in here*/
        callback(123, null);
    }
    catch(Exception ex)
    {
        callback(default(int), ex);
    }
}

Kolbaki nie są niczym nowym, bo praktycznie każda technologia je wspiera: C++, Javascript, Python, PHP, Java (….aaa nie, przepraszam! Java jednak nie :D), dlatego adaptacja takiej metody do czegoś, co zwróci Taska z wykorzystaniem TaskCompletionSource to nic trudnego.

public Task GetNextInt()
{
    var completionSource = new TaskCompletionSource();

    GetNextInt((result, exception) =>
    {
        if (exception != null)
        {
            completionSource.SetException(exception);
        }
        else
        {
            completionSource.SetResult(result);
        }
    });

    return completionSource.Task;
}

Taki kod może spowodować przewrócenie się aplikacji. Może się zdarzyć się że callback zostanie wywoływany kilka razy (kompilacja tego nie pilnuje). TaskCompletionSource nie lubi wielokrotnego wywoływania SetException albo SetResult i zgłasza wyjątek. W takim wypadku należy upomnieć twórcę kodu że robi źle, jeżeli to nie pomaga należy użyć środków przymusu bezpośredniego, jak to nie skutkuje należy upewnić się że napisał taki kod ostatni raz. Jeżeli jednak i to jest niemożliwe, ponieważ ktoś już go zdążył zastrzelić należy spróbować zorientować który kolbak jest tym właściwym, jeżeli pierwszy jest dobry wystarczająco to klasa TaskCompletionSource posiada metody TrySetResult i TrySetException, które nie powodują wyjątku przy wielokrotnym wywoływaniu.

Powyższy kod może nie jest może skomplikowany, ale przy pisaniu go 20 raz można się pomylić, dlatego dobrym pomysłem może być napisanie takiego helpera:

public Task Invoke<T>(Action<T> methodToInvoke)
{
    var completionSource = new TaskCompletionSource<T>();
    methodToInvoke((result, exception) =>
    {
        if (exception != null)
        {
            completionSource.SetException(exception);
        }
        else
        {
            completionSource.SetResult(result);
        }
    });
}

Po jego użyciu kod skraca się do:

public Task GetNextInt()
{
    return Invoke(GetNextInt);
}

Kiedy natomiast metoda przyjmuje argumenty:

public Task GetNextInt(string argument)
{
    return Invoke((handler) => GetNextInt(argument, handler));
}

Użycie takiego helpera ma sens w sytuacji, kiedy API używa typu Action jako callbacka, ale również w memencie kiedy API używa innego typu (wtedy najlepiej jest użyć go do zmiany typu przyjmowanego jako argument helpera).

Problem zaczyna się gdy API używa callbacków różnego typu, ale jak już wspomniełem nasz staruszek C# ma wiele ograniczeń i nie potrafi „w locie” przetłumaczyć typów oznaczanych słowem delegate, nawet jeżeli mają takie same wejścia i wyjścia. Przykładem może być Action<object, eventarg=””> – nie da się przekazać tej akcji jako EventHandler. W takiej sytuacji można pisać wszystko z palca, napisać generator kodu lub odpuścić i wyciągnąć jakieś wnioski na przyszłość.</object,>

Promise (obiecanki-cacanki)

Obiecanki to bardzo popularny mechanizm używany w wielu technologiac, w .Net raczej nie występuje, gdyż pojawiło się async/await, jednak można spotkać pewne chałupnicze implementacje. Po co jednak słowa – lepiej będzie, gdy spojrzycie na kod, gdyż wyraża on więcej niż tysiąc słów. Jeżeli w aplikacji występuje taki kod:

GetNextInt()
    .OnDone(result => {
        /* Wyświetl wynik */
        MessageBox.Show("Wynik to " + result);
    }).OnError(error => {
        MessageBox.ShowError("Coś poszło nie tak.")
    }).Always(() => {
        /* Schowaj kręcioła */
        HideSpinner();
    });

to na 99% GetNextInt zwraca Promise ewentualnie Future. Adaptacja Promise do klasy Task jest nawet łatwiejsza niż kolbaka, sami zobaczcie:

public Taks<T> Wrap(this IPromise<T> promise)
{
    var taskCompletionSource = new TaskCompletionSource<T>();
    promise
        .OnDone(result => {
            taskCompletionSource.SetResult(result)
        }).OnError(error => {
            taskCompletionSource.SetException(error);
        });
    return taskCompletionSource.Task;
}

ale może być jeszcze łatwiejsza:

public Taks<T> Wrap<T>(this IPromise<T> promise)
{
    var taskCompletionSource = new TaskCompletionSource<T>();
    promise
        .OnDone(taskCompletionSource.SetResult)
        .OnError(taskCompletionSource.SetException);
    return taskCompletionSource.Task;
}

Na dziś – tyle. Jednak to jeszce nie koniec … cdn.

Kod źródłowy projektu stworzonego na potrzeby tego postu udostępniam na Githubie