The Maybe Monad via async / tunggu di C # (tanpa Task ov!)


Jenis pengembalian asinkron generik adalah fitur baru yang diperkenalkan di C # 7 yang memungkinkan Anda untuk menggunakan tidak hanya Tugas sebagai jenis pengembalian metode asinkron ( asinkron / menunggu ), tetapi juga jenis lain (kelas atau struktur) yang memenuhi persyaratan tertentu.


Pada saat yang sama, async / await adalah cara untuk secara berurutan memanggil serangkaian fungsi tertentu dalam konteks tertentu, yang merupakan inti dari pola desain Monad . Pertanyaannya adalah, bisakah kita menggunakan async / menunggu untuk menulis kode yang berperilaku seolah-olah kita menggunakan monads? Ternyata ya (dengan beberapa pemesanan). Misalnya, kode di bawah ini mengkompilasi dan berfungsi:


async Task Main() { foreach (var s in new[] { "1,2", "3,7,1", null, "1" }) { var res = await Sum(s).GetMaybeResult(); Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString()); } // 3, 11, Nothing, Nothing } async Maybe<int> Sum(string input) { var args = await Split(input);//   var result = 0; foreach (var arg in args) result += await Parse(arg);//   return result; } Maybe<string[]> Split(string str) { var parts = str?.Split(',').Where(s=>!string.IsNullOrWhiteSpace(s)).ToArray(); return parts == null || parts.Length < 2 ? Maybe<string[]>.Nothing() : parts; } Maybe<int> Parse(string str) => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing(); 

Selanjutnya, saya jelaskan cara kerja kode ini ...


Jenis Pengembalian Asinkron Generik


Pertama-tama, mari cari tahu apa yang diperlukan untuk menggunakan tipe kita sendiri (misalnya, kelas <A> MyAwaitable ) sebagai jenis hasil dari beberapa fungsi yang tidak sinkron. Dokumentasi mengatakan bahwa jenis ini harus memiliki:


  1. Metode GetAwaiter () , yang mengembalikan objek tipe yang mengimplementasikan antarmuka INotifyCompletion , dan juga memiliki properti IsCompleted bool dan metode T GetResult () ;


  2. [AsyncMethodBuilder (Type)] - atribut yang menunjukkan tipe yang akan bertindak sebagai " Method Builder ", misalnya MyAwaitableTaskMethodBuilder <T> . Jenis ini harus berisi metode berikut:


    • statis Buat ()
    • Mulai (stateMachine)
    • SetResult (hasil)
    • SetException (pengecualian)
    • SetStateMachine (stateMachine)
    • AwaitOnCompleted (menunggu, stateMachine)
    • AwaitUnsafeOnCompleted (menunggu, stateMachine)
    • Tugas


Contoh implementasi sederhana dari MyAwaitable dan MyAwaitableTaskMethodBuilder
 [AsyncMethodBuilder(typeof(MyAwaitableTaskMethodBuilder<>))] public class MyAwaitable<T> : INotifyCompletion { private Action _continuation; public MyAwaitable() { } public MyAwaitable(T value) { this.Value = value; this.IsCompleted = true; } public MyAwaitable<T> GetAwaiter() => this; public bool IsCompleted { get; private set; } public T Value { get; private set; } public Exception Exception { get; private set; } public T GetResult() { if (!this.IsCompleted) throw new Exception("Not completed"); if (this.Exception != null) { ExceptionDispatchInfo.Throw(this.Exception); } return this.Value; } internal void SetResult(T value) { if (this.IsCompleted) throw new Exception("Already completed"); this.Value = value; this.IsCompleted = true; this._continuation?.Invoke(); } internal void SetException(Exception exception) { this.IsCompleted = true; this.Exception = exception; } void INotifyCompletion.OnCompleted(Action continuation) { this._continuation = continuation; if (this.IsCompleted) { continuation(); } } } public class MyAwaitableTaskMethodBuilder<T> { public MyAwaitableTaskMethodBuilder() => this.Task = new MyAwaitable<T>(); public static MyAwaitableTaskMethodBuilder<T> Create() => new MyAwaitableTaskMethodBuilder<T>(); public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine => stateMachine.MoveNext(); public void SetStateMachine(IAsyncStateMachine stateMachine) { } public void SetException(Exception exception) => this.Task.SetException(exception); public void SetResult(T result) => this.Task.SetResult(result); public void AwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine => this.GenericAwaitOnCompleted(ref awaiter, ref stateMachine); public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine => this.GenericAwaitOnCompleted(ref awaiter, ref stateMachine); public void GenericAwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine => awaiter.OnCompleted(stateMachine.MoveNext); public MyAwaitable<T> Task { get; } } 

Sekarang kita dapat menggunakan MyAwaitable sebagai jenis hasil dari metode asinkron:


 private async MyAwaitable<int> MyAwaitableMethod() { int result = 0; int arg1 = await this.GetMyAwaitable(1); result += arg1; int arg2 = await this.GetMyAwaitable(2); result += arg2; int arg3 = await this.GetMyAwaitable(3); result += arg3; return result; } private async MyAwaitable<int> GetMyAwaitable(int arg) { await Task.Delay(1);//   return await new MyAwaitable<int>(arg); } 

Kode ini berfungsi, tetapi untuk memahami esensi persyaratan untuk kelas MyAwaitable, mari kita lihat apa yang dilakukan preprocessor C # dengan metode MyAwaitableMethod . Jika Anda menjalankan beberapa dekompiler .NET compiler (misalnya, dotPeek), Anda akan melihat bahwa metode asli telah diubah sebagai berikut:


 private MyAwaitable<int> MyAwaitableMethod() { var stateMachine = new MyAwaitableMethodStateMachine(); stateMachine.Owner = this; stateMachine.Builder = MyAwaitableTaskMethodBuilder<int>.Create(); stateMachine.State = 0; stateMachine.Builder.Start(ref stateMachine); return stateMachine.Builder.Task; } 

MyAwaitableMethodStateMachine

Ini sebenarnya kode yang disederhanakan, di mana saya melewatkan banyak optimasi untuk membuat kode yang dihasilkan kompiler dapat dibaca


 sealed class MyAwaitableMethodStateMachine : IAsyncStateMachine { public int State; public MyAwaitableTaskMethodBuilder<int> Builder; public BuilderDemo Owner; private int _result; private int _arg1; private int _arg2; private int _arg3; private MyAwaitableAwaiter<int> _awaiter1; private MyAwaitableAwaiter<int> _awaiter2; private MyAwaitableAwaiter<int> _awaiter3; private void SetAwaitCompletion(INotifyCompletion awaiter) { var stateMachine = this; this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine); } void IAsyncStateMachine.MoveNext() { int finalResult; try { label_begin: switch (this.State) { case 0: this._result = 0; this._awaiter1 = this.Owner.GetMyAwaitable(1).GetAwaiter(); this.State = 1; if (!this._awaiter1.IsCompleted) { this.SetAwaitCompletion(this._awaiter1); return; } goto label_begin; case 1:// awaiter1    this._arg1 = this._awaiter1.GetResult(); this._result += this._arg1; this.State = 2; this._awaiter2 = this.Owner.GetMyAwaitable(2).GetAwaiter(); if (!this._awaiter2.IsCompleted) { this.SetAwaitCompletion(this._awaiter2); return; } goto label_begin; case 2:// awaiter2    this._arg2 = this._awaiter2.GetResult(); this._result += this._arg2; this.State = 3; this._awaiter3 = this.Owner.GetMyAwaitable(3).GetAwaiter(); if (!this._awaiter3.IsCompleted) { this.SetAwaitCompletion(this._awaiter3); return; } goto label_begin; case 3:// awaiter3    this._arg3 = this._awaiter3.GetResult(); this._result += this._arg3; finalResult = this._result; break; default: throw new Exception(); } } catch (Exception ex) { this.State = -1; this.Builder.SetException(ex); return; } this.State = -1; this.Builder.SetResult(finalResult); } } 

Setelah memeriksa kode yang dihasilkan, kami melihat bahwa Pembuat Metode memiliki tanggung jawab berikut:


  1. Organisasi panggilan ke metode MoveNext () yang mentransfer mesin status yang dihasilkan ke status berikutnya.
  2. Membuat objek yang akan mewakili konteks operasi asinkron ( public MyAwaitable<T> Task { get; } )
  3. Menanggapi terjemahan mesin status yang dihasilkan ke status akhir: SetResult atau SetException .

Dengan kata lain, dengan bantuan Method Builder kita dapat memperoleh kendali atas bagaimana metode asinkron dijalankan, dan ini terlihat seperti peluang yang akan membantu kita mencapai tujuan kita - penerapan perilaku Mungkin monad.


Tapi apa yang baik tentang monad ini? ... Sebenarnya, Anda dapat menemukan banyak artikel tentang monad ini di Internet, jadi di sini saya hanya akan menjelaskan dasar-dasarnya.


Mungkin Monad


Singkatnya, Mungkin monad adalah pola desain yang memungkinkan Anda untuk menghentikan panggilan fungsi jika beberapa fungsi dari rantai tidak dapat mengembalikan hasil yang bermakna (misalnya, parameter input tidak valid).


Bahasa pemrograman yang secara historis sangat penting telah memecahkan masalah ini dengan dua cara:


  1. Banyak logika kondisional
  2. Pengecualian

Kedua metode memiliki kerugian yang jelas, sehingga pendekatan alternatif telah diusulkan:


  1. Buat jenis yang dapat di dua negara: "Beberapa nilai" dan "Tidak ada nilai" (" Tidak ada ") - sebut saja itu Mungkin
  2. Buat fungsi (sebut saja SelectMany ) yang mengambil 2 argumen:
    2.1. Mungkin keberatan
    2.2. Fungsi selanjutnya dari daftar panggilan. Fungsi ini juga harus mengembalikan objek bertipe Mungkin , yang mungkin mengandung beberapa jenis nilai yang dihasilkan atau berada dalam status Tidak ada jika hasilnya tidak dapat diperoleh (misalnya, parameter yang salah diteruskan ke fungsi)
  3. Fungsi SelectMany memeriksa objek bertipe Mungkin dan jika berisi nilai yang dihasilkan, maka hasil ini diekstraksi dan diteruskan sebagai argumen ke fungsi berikutnya dari rantai panggilan (dilewatkan sebagai argumen kedua). Jika objek Maybe dalam status Nothing , maka SelectMany akan segera mengembalikan Nothing .


Dalam C #, ini dapat diimplementasikan sebagai berikut:


 public struct Maybe<T> { public static implicit operator Maybe<T>(T value) => Value(value); public static Maybe<T> Value(T value) => new Maybe<T>(false, value); public static readonly Maybe<T> Nothing = new Maybe<T>(true, default); private Maybe(bool isNothing, T value) { this.IsNothing = isNothing; this._value = value; } public readonly bool IsNothing; private readonly T _value; public T GetValue() => this.IsNothing ? throw new Exception("Nothing") : this._value; } public static class MaybeExtensions { public static Maybe<TRes> SelectMany<TIn, TRes>( this Maybe<TIn> source, Func<TIn, Maybe<TRes>> func) => source.IsNothing ? Maybe<TRes>.Nothing : func(source.GetValue()); } 

dan contoh penggunaan:


 static void Main() { for (int i = 0; i < 10; i++) { var res = Function1(i).SelectMany(Function2).SelectMany(Function3); Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString()); } Maybe<int> Function1(int acc) => acc < 10 ? acc + 1 : Maybe<int>.Nothing; Maybe<int> Function2(int acc) => acc < 10 ? acc + 2 : Maybe<int>.Nothing; Maybe<int> Function3(int acc) => acc < 10 ? acc + 3 : Maybe<int>.Nothing; } 

Mengapa "SelectMany"?

Saya pikir beberapa dari Anda mungkin bertanya-tanya: "Mengapa penulis menyebut fungsi ini" SelectMany "? Sebenarnya, ada alasan untuk ini - dalam C # preprocessor menyisipkan panggilan Select Many ketika memproses ekspresi yang ditulis dalam Query Notation , yang, pada dasarnya, adalah “Gula sintaksis” untuk rantai panggilan yang kompleks. (Anda dapat menemukan informasi lebih lanjut tentang ini di artikel saya sebelumnya ).


Bahkan, kita dapat menulis ulang kode sebelumnya sebagai berikut:


 var res = Function1(i) .SelectMany(x2 => Function2(x2).SelectMany(x3 => Function3(x3.SelectMany<int, int>(x4 => x2 + x3 + x4))); 

dengan demikian mendapatkan akses ke kondisi antara (x2, x3), yang dalam beberapa kasus bisa sangat nyaman. Sayangnya, membaca kode seperti itu sangat sulit, tetapi untungnya, C # memiliki Notasi Kueri dengan bantuan kode seperti itu akan terlihat jauh lebih mudah:


 var res = from x2 in Function1(i) from x3 in Function2(x2) from x4 in Function3(x3) select x2 + x3 + x4; 

Untuk membuat kode ini dikompilasi, kita perlu sedikit memperluas fungsi Select Many :


 public static Maybe<TJ> SelectMany<TIn, TRes, TJ>( this Maybe<TIn> source, Func<TIn, Maybe<TRes>> func, Func<TIn, TRes, TJ> joinFunc) { if (source.IsNothing) return Maybe<TJ>.Nothing; var res = func(source.GetValue()); return res.IsNothing ? Maybe<TJ>.Nothing : joinFunc(source.GetValue(), res.GetValue()); } 

Ini adalah bagaimana kode dari judul artikel akan terlihat jika Anda menulis ulang menggunakan implementasi "klasik"
 static void Main() { foreach (var s in new[] {"1,2", "3,7,1", null, "1"}) { var res = Sum(s); Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString()); } Console.ReadKey(); } static Maybe<int> Sum(string input) => Split(input).SelectMany(items => Acc(0, 0, items)); //       "Maybe" static Maybe<int> Acc(int res, int index, IReadOnlyList<string> array) => index < array.Count ? Add(res, array[index]) .SelectMany(newRes => Acc(newRes, index + 1, array)) : res; static Maybe<int> Add(int acc, string nextStr) => Parse(nextStr).SelectMany<int, int>(nextNum => acc + nextNum); static Maybe<string[]> Split(string str) { var parts = str?.Split(',') .Where(s => !string.IsNullOrWhiteSpace(s)).ToArray(); return parts == null || parts.Length < 2 ? Maybe<string[]>.Nothing : parts; } static Maybe<int> Parse(string value) => int.TryParse(value, out var result) ? result : Maybe<int>.Nothing; 

Kode ini tidak terlihat sangat elegan, karena C # pada awalnya tidak dirancang sebagai bahasa fungsional, tetapi pendekatan ini cukup umum dalam bahasa fungsional "nyata".


Async mungkin


Inti dari Mungkin monad adalah untuk mengontrol rantai pemanggilan fungsi, tetapi inilah yang dilakukan oleh async / tunggu . Jadi, mari kita coba menggabungkannya. Pertama, kita perlu membuat tipe Maybe kompatibel dengan fungsi asinkron, dan kita sudah tahu bagaimana mencapainya:


 [AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : INotifyCompletion { ... public Maybe<T> GetAwaiter() => this; public bool IsCompleted { get; private set; } public void OnCompleted(Action continuation){...} public T GetResult() =>... } 

Sekarang mari kita lihat bagaimana implementasi "klasik" Mungkin dapat ditulis ulang sebagai mesin negara yang terbatas sehingga kita dapat menemukan kesamaan:


 static void Main() { for (int i = 0; i < 10; i++) { var stateMachine = new StateMachine(); stateMachine.state = 0; stateMachine.i = i; stateMachine.MoveNext(); var res = stateMachine.Result; Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString()); } Console.ReadKey(); } class StateMachine { public int state = 0; public int i; public Maybe<int> Result; private Maybe<int> _f1; private Maybe<int> _f2; private Maybe<int> _f3; public void MoveNext() { label_begin: switch (this.state) { case 0: this._f1 = Function1(this.i); this.state = Match ? -1 : 1; goto label_begin; case 1: this._f2 = Function2(this._f1.GetValue()); this.state = this._f2.IsNothing ? -1 : 2; goto label_begin; case 2: this._f3 = Function3(this._f2.GetValue()); this.state = this._f3.IsNothing ? -1 : 3; goto label_begin; case 3: this.Result = this._f3.GetValue(); break; case -1: this.Result = Maybe<int>.Nothing; break; } } } 

Jika kita membandingkan mesin keadaan ini dengan preprocessor C # yang dihasilkan (lihat “MyAwaitableMethodStateMachine” di atas), kita dapat melihat bahwa Mungkin pengecekan status dapat diimplementasikan di dalam:


 this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine); 

di mana ref awaiter adalah objek bertipe Maybe . Masalahnya di sini adalah bahwa kita tidak dapat mengatur mesin ke status "final" (-1), tetapi apakah ini berarti bahwa kita tidak dapat mengontrol alur eksekusi? Ini sebenarnya tidak demikian. Faktanya adalah bahwa untuk setiap tindakan asinkron, C # mengatur fungsi panggilan balik untuk melanjutkan tindakan asinkron melalui antarmuka INotifyCompletion , jadi jika kita ingin memutus aliran eksekusi, kita cukup memanggil fungsi panggil balik ketika kita tidak dapat melanjutkan rantai operasi asinkron.
Masalah lain di sini adalah bahwa mesin keadaan yang dihasilkan melewati langkah berikutnya (sebagai fungsi panggilan balik) ke urutan operasi asinkron saat ini, tetapi kita membutuhkan fungsi panggilan balik untuk urutan asli yang akan memungkinkan kita untuk mem-bypass semua rantai operasi asinkron yang tersisa (dari tingkat bersarang) :



Jadi, kita perlu mengaitkan aksi asinkron yang tersarang dengan pembuatnya. Kita dapat melakukan ini menggunakan Method Builder kami , yang memiliki tautan ke operasi asinkron saat ini - Tugas . Tautan ke semua operasi asinkron anak akan diteruskan ke AwaitOnCompleted (ref awaiter) sebagai awaiter , jadi kita hanya perlu memeriksa apakah parameternya adalah instance dari Maybe , lalu tetapkan Mungkin saat ini sebagai induk untuk tindakan anak saat ini:


 [AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : IMaybe, INotifyCompletion { private IMaybe _parent; void IMaybe.SetParent(IMaybe parent) => this._parent = parent; ... } public class MaybeTaskMethodBuilder<T> { ... private void GenericAwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { if (awaiter is IMaybe maybe) { maybe.SetParent(this.Task); } awaiter.OnCompleted(stateMachine.MoveNext); } ... } 

Sekarang semua objek bertipe Mungkin dapat digabungkan ke dalam hierarki, sebagai hasilnya, kita akan mendapatkan akses ke panggilan akhir seluruh hierarki (metode Keluar ) dari sembarang simpul:


 [AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : IMaybe, INotifyCompletion { private Action _continuation; private IMaybe _parent; ... public void OnCompleted(Action continuation) { ... this._continuation = continuation; ... } ... void IMaybe.Exit() { this.IsCompleted = true; if (this._parent != null) { this._parent.Exit(); } else { this._continuation(); } } ... } 

Metode Exit harus dipanggil ketika, saat menavigasi melalui hierarki, kami menemukan objek Maybe yang sudah dihitung dalam keadaan Nothing . Objek Maybe tersebut dapat dikembalikan dengan metode seperti ini:


 Maybe<int> Parse(string str) => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing(); 

Untuk menyimpan status Maybe , buat struktur terpisah baru:


 public struct MaybeResult { ... private readonly T _value; public readonly bool IsNothing; public T GetValue() => this.IsNothing ? throw new Exception("Nothing") : this._value; } [AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : IMaybe, INotifyCompletion { private MaybeResult? _result; ... internal Maybe() { }//Used in async method private Maybe(MaybeResult result) => this._result = result;// ""  ... } 

Pada saat mesin negara asinkron memanggil (melalui Method Builder ) metode OnCompleted dari instance Maybe yang sudah dihitung dan itu dalam keadaan Nothing , kita dapat memecah seluruh aliran:


 public void OnCompleted(Action continuation) { this._continuation = continuation; if(this._result.HasValue) { this.NotifyResult(this._result.Value.IsNothing); } } internal void SetResult(T result) //  "method builder"     { this._result = MaybeResult.Value(result); this.IsCompleted = true; this.NotifyResult(this._result.Value.IsNothing); } private void NotifyResult(bool isNothing) { this.IsCompleted = true; if (isNothing) { this._parent.Exit();//    } else { this._continuation?.Invoke(); } } 

Sekarang hanya ada satu pertanyaan - bagaimana mendapatkan hasil dari Asynchronous Maybe di luar cakupannya (metode asynchronous mana pun yang tipe kembaliannya tidak Maybe ). Jika Anda mencoba menggunakan hanya kata kunci yang menunggu dengan instance Maybe , maka pengecualian akan diberikan oleh kode ini:


 [AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : IMaybe, INotifyCompletion { private MaybeResult? _result; public T GetResult() => this._result.Value.GetValue(); } ... public struct MaybeResult { ... public T GetValue() => this.IsNothing ? throw new Exception("Nothing") : this._value; } 

Untuk mengatasi masalah ini, kita cukup menambahkan penunggu baru yang akan mengembalikan seluruh struktur MaybeResult secara keseluruhan, dan kemudian kita dapat menulis kode ini:


 var res = await GetResult().GetMaybeResult(); if(res.IsNothing){ ... } else{ res.GetValue(); ... }; 

Itu saja untuk saat ini. Dalam contoh kode, saya menghilangkan beberapa detail untuk fokus hanya pada bagian yang paling penting. Anda dapat menemukan versi lengkapnya di github .


Bahkan , saya tidak akan merekomendasikan menggunakan pendekatan di atas dalam kode kerja apa pun, karena memiliki satu masalah yang signifikan - ketika kita memutuskan untaian eksekusi, menyebabkan kelanjutan dari operasi asinkron root (dengan tipe Maybe ), kita mematahkan SEMUA sama sekali! termasuk semua blok akhirnya (ini adalah jawaban untuk pertanyaan "Apakah akhirnya blok selalu dipanggil?"), sehingga semua pernyataan yang menggunakan tidak akan berfungsi dengan baik, yang dapat menyebabkan kebocoran sumber daya. Masalah ini dapat diselesaikan jika alih-alih langsung memanggil kelanjutan, kami akan memunculkan pengecualian khusus yang akan ditangani secara implisit (di sini Anda dapat menemukan versi ini ), tetapi solusi ini jelas memiliki batas kinerja (yang mungkin dapat diterima dalam beberapa skenario). Dalam versi saat ini dari kompiler C #, saya tidak melihat solusi lain, tapi mungkin ini suatu hari akan berubah di masa depan.


Namun, pembatasan ini tidak berarti bahwa semua teknik yang dijelaskan dalam artikel ini sama sekali tidak berguna, mereka dapat digunakan untuk mengimplementasikan monad lain yang tidak memerlukan perubahan pada utas, misalnya, "Pembaca". Bagaimana cara mengimplementasikan "Reader" monad ini melalui async / tunggu, saya akan tunjukkan di artikel selanjutnya .

Source: https://habr.com/ru/post/id468017/


All Articles