LoginSignup
3
3

More than 5 years have passed since last update.

7つの言語 7つの世界 ~第8章 Haskell 3日目~

Posted at

はじめに

 少し時間が空いてしまったが、Haskellの3日目を勉強する。

3日目本編「精神融合」

クラスと型

 Haskellの型システムは、型推論があるため、プログラマにとっても負担の少ない強力なシステムである。また、微妙なプログラミングエラーでさえ補足するくらいに堅牢でもある。さらには、多相的(ポリモーフィック)である。すなわち、見かけの違う同じ型を同じものとして扱うことが出来る。ここでは、型の例をいくつか紹介した後、独自の型を作成してみる。

基本型

 これまでに登場したいくつかの基本型について復習する。tオプションをオンにしてから始めよう。

Prelude> :set +t
Prelude> 'c'
'c'
it :: Char
Prelude> "abc"
"abc"
it :: [Char]
Prelude> ['a', 'b', 'c']
"abc"
it :: [Char]

 itは常に最後に入力したものの値を示し、::は「その型は?」と解釈できる。Haskellでは文字は常にプリミティブ型である。文字列は文字のリストである。他の例も見てみよう。

Prelude> "abc" == ['a', 'b', 'c']
True
it :: Bool
Prelude> True
True
it :: Bool
Prelude> False
False
it :: Bool
ユーザ定義の型

 ユーザ独自のデータ型を定義するにはdataキーワードを使用する。最も簡単な型宣言では、有限個の値を列挙する。例えばブール型は次のように定義される。

 data Boolean = True | False

 これは、Boolean型は単一値、すなわちTrueまたはFalseをとるという意味である。同じ要領でユーザ独自の型を定義できる。2種類の組(スーツ)と5つの数(ランク)からなる簡素化されたトランプを考えてみる。

cards.hs
module Main where
        data Suit = Spades | Hearts
        data Rank = Ten | Jack | Queen | King | Ace

 この例では、SuitとRankが型構成子である。dataを用いて、新しいユーザ定義の型を作成している。これを実行してみる。

Prelude> :load cards.hs 
[1 of 1] Compiling Main             ( cards.hs, interpreted )
Ok, modules loaded: Main.
*Main> Hearts 

<interactive>:3:1:
    No instance for (Show Suit) arising from a use of print
    In a stmt of an interactive GHCi command: print it

 エラーになった!このエラーメッセージは「コンソールがこれらの値を表示しようとしたが、その方法がわからない」と言っている。ユーザ定義のデータ型を宣言する時、show関数を利用できるようにする略記法がある。次のように指定する。

cards-with-show.hs
module Main where
        data Suit = Spades | Hearts deriving (Show)
        data Rank = Ten | Jack | Queen | King | Ace deriving (Show)
        type Card = (Rank, Suit)
        type Hand = [Card]

 いくつか型の別名を追加している。Cardは組(スーツ)と数(ランク)のタプルである。Handはカードのリストである。これらの型を用いて、次のような新しい関数を作成する。card-with-show.hsの続きに記述する。

cards-with-show.hs
        value :: Rank -> Integer
        value Ten = 1
        value Jack = 2
        value Queen = 3
        value King = 4
        value Ace = 5

        cardValue :: Card -> Integer
        cardValue (rank, suit) = value rank

 どのようなカードゲームでもそうだが、カードの数(ランク)を割り当てることが出来なければならない。組(スーツ)はここでは関係ない。そこで、Rankのvalueを計算する関数とcaedValueを計算する関数を定義した。それでは、実行してみる。

Prelude> :load cards-with-show.hs 
[1 of 1] Compiling Main             ( cards-with-show.hs, interpreted )
Ok, modules loaded: Main.
*Main> cardValue (Ten, Hearts)
1

 ユーザ定義の型の複雑なタプルを使用している。型システムがこちらの意図を明確に表しているので、処理内容を容易に推測できる。

関数とポリモーフィズム

 先ほど、いくつか関数型が出てきたので、簡単な関数で見てみる。

 backwards [] = []
 backwards (h:t) = backwards t ++ [h]

 この関数に型を追加してみる。次のようになる。

 backwards :: Hand -> Hand

 これだけだと、backwards関数で操作できる対象が、1種類のリスト、つまり、cardのリストだけが限定される。本当は次のようにしたい。

 backwards :: [a] -> [a]
 backwards [] = []
 backwards (h:t) = backwards t ++ [h]

 これで、backwards関数は、ポリモーフィック(多相的)になった。[a]は、任意の型のリストを使えるという意味である。つまり、型aのリストを返す関数を定義できる。[a] -> [a]と書くことによって、この関数で使える型のテンプレートを作成している。これで、最初がIntegerのリストなら返されるのもIntegerのリストであるとコンパイラに伝えたことになる。型をごまかせないだけの情報をHaskellに提供したことになる。
 ポリモーフィックなデータ型を作成してみる。以下に、同じ型を3つの点からなる3要素のタプルを作る。

triplet.hs
Prelude> odd 5
True
Prelude> filter odd [1, 2, 3, 4, 5]
[1,3,5]

 odd関数は、奇数を判定する。filterによって、リスト内の要素でodd関数がTrueの要素をリストで返している。

 次に、foldlとfoldrの使い方を見ていく。これは、ClojureやScalaと同じようである。

module Main where
        data Triplet a = Trio a a a deriving (Show)

 左辺は、data Triplet a となっている。この例では、aは型変数である。これにより、同じ型の要素を持つ3要素のタプルはTriplet a という型で表現できるようになる。試してみよう。

Prelude> :load triplet.hs 
[1 of 1] Compiling Main             ( triplet.hs, interpreted )
Ok, modules loaded: Main.
*Main> :t Trio 'a' 'b' 'c'
Trio 'a' 'b' 'c' :: Triplet Char

 上の例では、データ構成子Trioを使って3要素のタプルを作成している。データ構成子については、次のセクションで説明があるようだ。型宣言の通り、結果はTriplet a、より厳密にはTriplet charとなっており、Triplet aを要求する任意の関数で使える。上の例は、同じ型の3つの要素からなる型を記述する。型のテンプレート全体を作成している。

再帰型

 再帰的な型は定義することも出来る。例えばツリーを考えてみる。方法はいくつかあるが、ここで作成するツリーでは、リーフノードに値が格納される。ノードは、リーフまたはツリーのリストのどちらかである。このツリーは次のように記述できる。

tree.hs
module Main where
        data Tree a = Children [Tree a] | Leaf a deriving (Show)

 この定義では、1つの型構成子Treeと2つのデータ構成子ChildrenとLeafを使っている。次のように、これら全てを使ってツリーを表現している。

Prelude> :load tree.hs 
[1 of 1] Compiling Main             ( tree.hs, interpreted )
Ok, modules loaded: Main.
*Main> let leaf = Leaf 1
*Main> leaf
Leaf 1

 手始めに、1つのリーフを持つツリーを作成する。新しいリーフは変数に代入する。データ構成子Leafの唯一の仕事は、型と値を一緒に保持することである。各値を取り出すには次のようにパターンマッチングを用いる。

*Main> let (Leaf value) = leaf
*Main> value
1

 もう少し複雑なツリーを作成してみる。

*Main> Children[Leaf 1, Leaf 2]
Children [Leaf 1,Leaf 2]
*Main> let tree = Children[Leaf 1, Children [Leaf 2, Leaf 3]]
*Main> tree
Children [Leaf 1,Children [Leaf 2,Leaf 3]]

 どちらもリーフである2つの子ノードを持つツリーを作成する。次に、左側がリーフ、右側がツリーであるような2つのノードを持つツリーを作成する。ここでも、パターンマッチングを使って、各ノード値を取り出すことが出来る。さらに複雑にしてみる。この定義は再帰的なので、letとパターンマッチングを使って、いくらでも深いツリーを作成できる。

*Main> let (Children ch) = tree
*Main> ch
[Leaf 1,Children [Leaf 2,Leaf 3]]
*Main> let (fst:tail) = ch
*Main> fst
Leaf 1

 設計の意図は型から明解であり、作業に必要な部分はマッチングで引き剥がすことが可能である。この設計戦略には明らかにオーバーヘッドが伴うが、抽象化の度合いが深くなってくると、そうした余分なオーバーヘッドという代価を支払うだけの価値が出てくることがある。この例では、型システムによって、個々の型構成子に関数を結びつけることができる。ツリーの深さを調べる関数を作成してみる。先ほどのプログラムの続きに記述する。

tree.hs
module Main where
        data Tree a = Children [Tree a] | Leaf a deriving (Show)

        depth (Leaf _) = 1
        depth (Children c) = 1 + maximum (map depth c)

 追加した関数の最初のパターンはシンプルである。ツリーが1つのリーフである場合は、そのリーフの中身に関係なく、ツリーの深さは1である。
 次のパターンはもう少し複雑である。Children に対して depth を呼び出す場合は、maximum ( map depth c) に 1 を足す。maximum関数はリストの中の最大要素を返す。map depth c については、前にも見たように、すべての子ノードの深さのリストを返す。作業に必要なデータ構造の一部をマッチングさせるのにデータ構成子を使う方法が、この例でわかる。

クラス

 ここまで、型システムとその動作をいくつか例を挙げて見てきた。ユーザー定義の型構成子を作成し、型のテンプレートを用意した。また、型のテンプレートによって、データ型を定義し、それらのデータ型を使う関数を宣言した。実はHaskellには、型に関してもう1つ重要な概念がある。しかもその重要度はかなり大きい。それはクラスと呼ばれる概念である。とはいえ、オブジェクト指向のクラスではないので注意してほしい。このクラスにはデータは関係ない。Haskellでは多相性をうまく制御し、クラスを使って多重定義型を実現している。
 例えば、2つのブール値を足し算することはできないが、2つの数値なら足し算できる。Haskellでは、この判別を行うためにクラスを用いる。具体的にいうと、クラスとは、クラスとは、入力に応じて、どの演算が実行可能かを定義したものである。これは、Clojureのプロトコルと同じらしい。
 以下に、クラスの動作方法を解説する。クラスには、いくつかの関数シグネチャが定義されている。ある型がこれらのすべての関数をサポートしていれば、その型は当該クラスのインスタンスである。例えば、Haskellのライブラリには、Eqというクラスがある。
 このクラスの定義は次のようになる。

 Class Eq a where
  (==), (/=) :: a -> a -> Bool
 
  -- Minimal complete definition:
  -- (==) or (/=)
  x /= y = not (x == y)
  x == y = not (x /= y)

 したがって、ある型が==と/=の両方をサポートしていれば、その型はEqのインスタンスである。お決まりの実装を指定することもできる。また、あるインスタンスで上記のどちらか一方の関数を定義すれば、他方は何もしなくても使用可能となる。
 型クラスは継承をサポートしており、継承と聞いて期待するとおりの動作をする。例えば、Numクラスには、FractionalとRealの2つのサブクラスがある。Haskell98の大半の主要クラスの階層が図8.1の「Haskellの主要クラス」に示してあるので、気になった方は本書を手にとって見てみてほしい。これらのクラスのインスタンスは型であってデータオブジェクトではない点に注意してほしい。

モナド

 このセクションでは、モナドが必要となる理由を直感的に順を追って説明する。その後、モナドの構築方法について概説する。最後に、モナドの動作方法がよく理解できるシンタックスシュガーを紹介する。私もモナドという言葉自体初めて聞くのでどんなものか楽しみである。
 HaskellのWikiには、良いサンプルが掲載されているらしい。また、Understanding Monadsという記事にも、実用的な良い例が掲載されているらしいので、目を通しておくと良いだろう。

問題:酔った海賊

 ある海賊が宝探しの地図を探しているとする。この海賊は酔っているため、既知のポイントと既知の方向を選択し、宝のありかまでよろめき這いつくばって進んでいく。1回よろめくと2歩進み、1回腹ばいになると1歩進む。命令形言語では、次のように文を一続きに並べていく。ここで、vはスタート地点からの距離を保持する値である。
 def treasure_map(v)
  v = stagger(v)
  v = stagger(v)
  v = crawl(v)
  return( v )
 end
 treasure_map内ではいくつかの関数を呼び出す。これらの関数を呼び出すと、状態、すなわち移動した距離が逐次変化していく。問題は、可変状態が含まれる点である。関数型プログラミングでは、同じ問題を次のようにコーディングする。

drunken-pirate.hs
module Main where
        stagger :: (Nun t) => t -> t
        stagger d = d + 2
        crawl d = d + 1

        tresureMap d = 
                crawl (
                stagger (
                stagger d))

 見ての通り、この関数定義は読みづらい。stagger, stagger, crawl の順ではなく、crawl, stagger, stagger の順になっており、引数の位置もぎこちない。このような分かりにくい定義ではなく、複数の関数を数珠つなぎに出来る方法を使いたい。それには、次のようにlet式を用いる。

drunken-pirate.hs
letTresureMap (v, d) = let d1 = stagger d
                             d2 = stagger d1
                             d3 = crawl d2
                         in d3

 Haskellでは、let式を数珠つなぎにし、最終的な形をin文で表現できる。しかし、この定義も、不満が残るという点では最初の定義とほとんど変わらないらしい。入出力の型がどれも同じなのだから、この類の関数たちを簡単に組み合わせる方法があっても良さそうなものだ。stagger(crawl (x))を、stagger(x) → crawl(x)のような形で書けると嬉しい。これこそがモナドである。
 モナドを一言で表すと、特別なやり方で複数の関数を組み合わせるための方法だと言える。Haskellでは、様々な目的にモナドを使用する。純粋な関数型言語では、関数は同じ入力を与えられた時同じ結果を生成しなければならないので、I/Oのようなものを扱うのは難しい。I/Oでは、例えば、ファイルの中身の状態に応じて関数の処理内容も変えることが求められるからである。
 また、先ほどの酔った海賊のようなコードは状態を保持しているから動作する。モナドを使用すると、プログラムの状態をシミュレートできる。Haskellでは、do記法という特別な構文を用意している。これにより、命令形式でプログラムを書くことが出来る。do記法はモナドに依存している。
 最後に、Haskellでは、エラー条件のような簡単なことが難しい。というのは、関数が正常終了したかどうかによって、返されるモノの型が異なるからだ。これを解決するため、HasellではMaybeモナドを用意している。

モナドの構成要素

 モナドは、基本的なレベルでは、次の3つの基本要素で構成される。

  • コンテナとなるものの型を変数に取る型構成しを。コンテナとして使えるのは、単純な変数やリストなど、値を保持できるものならなんでもよい。このコンテナに関数を収めることになる。どのコンテナを選ぶかは、モナドで実行したいことによって異なる。
  • 関数をラップしてコンテナに格納するreturnという名前の関数。この名前は、後でdo記法について説明するときに分かるだろう。ここでは、returnは関数をラップしてモナドに入れ込むという点だけ覚えておいてほしい。
  • 関数をラップする>>= (バインド) という名前の関数。バインドを使って、関数を数珠つなぎにする。

 すべてのモナドは3つのルールを満たす必要がある。ここでそれらのルールについて簡単に触れておく。モナドm、関数f、値xについて、次のルールがある。

  • ラップした値はそのまま関数に渡すことが出来なければならない。(return x >>= f = f x)。
  • 情報を失うことなく値をアンラップ及びラップ出来なければならない(monad >>= return = monad)。
  • バインドをネストした場合と、それらをシーケンシャルに呼び出した場合とで結果が同じでなければならない((m >>= f) >>= g = m >>= (\x -> f x >>= g))。

 これらのルールについて詳細な解説は本書ではされていないが、これらのルールに従うことによって、情報を失うことなく、有用な変換が可能となる。
 理論はこれぐらいにして、早速簡単なモナドを作成する。

モナドを一から作成する

 まず必要なのは型構成子である。このモナドは、次のように1つの関数と1つの値を持つ。

drunken-monad.hs
module Main where
        data Position t = Position t deriving (Show)

        stagger (Position d) = Position (d + 2)
        crawl (Position d) = Position (d+ 1)

        rtn x = x
        x >>== f = f x

 モナドの3つの主要な要素は、型構成子、return、バインドである。このモナドは、考え得る限り最もシンプルなものらしい。型コンテナは、シンプルな型構成子data Position t = Position t である。ここでは単に、任意の型テンプレートに基いて基本型を定義しているだけである。次に関数を値としてラップするreturnが必要である。このモナドは非常にシンプルなので、モナド自身の値を返すだけで良い。その際、rtn x = xによって正しくラップしている。最後に関数を合成するためのバインドが必要である。このモナドのバインドは>>==と呼ばれ、対応する関数をモナド内の引数として呼び出しているだけである(x >>== f = f x)。ここでは、Haskellの組み込みのモナド関数と衝突するのを避けるために、>>==とreturnの代わりに、>>==とrtnを使用している。
 staggerとcrawlを、裸のintegerではなく、自家製のモナドを使うように書き換えている点にも注意した方が良いみたいである。では、このモナドをテストしてみる。私達が求めていた、関数の入れ子を関数の合成に変換するシンタックスになっているだろうか?

drunken-monad.hs
tresureMap pos = pos >>==
                 stagger >>==
                 stagger >>==
                 crawl >>==
                 rtn

 実行してみると、次のように期待通りに動くことがわかる。

Prelude> :load drunken-monad.hs 
[1 of 1] Compiling Main             ( drunken-monad.hs, interpreted )
Ok, modules loaded: Main.
*Main> tresureMap (Position 0)
Position 5
モナドとdo表記

 シンタックスはずいぶん良くなったが、更に良くするためのシンタックスシュガーも想像できる。Haskellのdo記法がまさにそのシンタックスシュガーである。do記法は、特にI/Oなどの問題を解くときにべんりらしい。次のコードは、do記法を用いて、コンソールから1行読み込み、その行を逆にして出力するものである。

io.hs
module Main where
        tryIO = do putStr "Enter your name: ";
                   line <- getLine ;
                   let { backwards = reverse line } ;
                   return ("Hello. Your name backwards is " ++ backwards)

 このプログラムの先頭は関数の宣言である点に注目してほしい。その後の簡単なdo表記が、モナドをくるむシンタックスシュガーになっている。このシンタックスシュガーによって、このプログラムはステートフルな命令形であるように感じられるが、実際にはモナドを使用している。次の構文ルールを知っておくと良いみたいだ。
 代入には<- を使う。GHCiでは、文をセミコロンに区切り、doの本体を含める。その中にlet文を中括弧で囲んで指定する。複数の行がある場合は、コードを:{と}:でラップして、それぞれを別の行に書く。さて、ここでモナドのラッピング構文をreturnと呼ぶ理由が明らかになる。この構文は、関数が返す結果を、do記法で吸収できる整理された形式で綺麗にパッケージ化する。このコードはステートフルな命令形言語で書いたように動作するが、実際にはモナドを使ってステートフルなやりとりを管理している。すべてのI/Oはしっかりとカプセル化されており、doブロック内のI/Oモナドの1つを使用して補足する必要がある。

様々な計算戦略

 すべてのモナドには計算戦略が関連付けられているようだ。酔った海賊の例で用いたidentity(恒等)モナドは、入力したものをそのまま返すものであった。我々はこのモナドを使って、入れ子になったプログラム構造を逐次化された平坦なプログラム構造に変換した。もう1つ別の例を見てみよう。奇妙に見えるかもしれないが、リストもモナドらしい。そのreturnとバインド(>>=)は次のように定義されている。

instance Monad [] where
    m >>= f = concatMap f m
    return x = [x]

 モナドには、コンテナと型構成子、関数をラップするreturnメソッド、関数をアンラップするバインドが必要なことを思い出してほしい。モナドはクラスであり、[]はそのクラスのインスタンスであり型構成子である。次に、結果をreturnとしてラップする関数が必要になってくる。
 リストの場合、関数をリスト内にラップする。アンラップするには、バインドがmapを用いてリストの各要素に対して関数を呼び出し、その結果を連結する。concatとmapは順番に適用されることが頻繁にあるため、両方を実行する便利な関数が用意されているが、単純にconcat (map f m)と書いても構わないようだ。
 リストモナドが動作する様子の感じをつかむために、do表記で書かれた次のスクリプトを見てみる。

Prelude> let cartesian (xs, ys) = do x <- xs; y <- ys; return (x, y)
Prelude> cartesian ([1..2], [3..4])
[(1,3),(1,4),(2,3),(2,4)]

 do記法とモナドを使って簡単な関数を作成した。xsのリストからxを、ysのリストからyを取り出し、xとyの各組み合わせを返す。ここからパスワードクラッカーを作るのは簡単である。

password.hs
module Main where
        crack = do x <- ['a'..'c'] ; y <- ['a'..'c'] ; x <- ['a'..'c'] ;
                let { password = [x, y, z] } ;
                if attempt password
                        then return (password, True)
                        else return (password, False)

        attempt pw = if pw == "cab" then True else False

 この例では、リストモナドを使って考えられるすべての組み合わせを計算している。このコンテキストでは、x <- [lst]は、「[lst]から取り出した各xについて」という意味である。Haskellに力仕事をさせている。ここまでくれば、後は作成した各パスワードを試すだけである。この問題を解くために使える計算戦略は他にもたくさん考えられるが(例えばリスト内包表記など)、この問題はリストモナドの背後にある計算戦略を示してくれる。

Maybeモナド

 ここまで、IdentityモナドとListモナドについて見てきた。後者については、モナドが中心をなす計算戦略をサポートしていることを学んだ。このセクションでは、Maybeモナドについて解説する。ここではMaybeモナドを使って一般的なプログラミングの問題を処理する。すなわち、関数によってはエラーになる可能性があるという問題だ。というと、データベースや通信の分野の話をしていると思うかもしれないが、それよりはるかに簡単なAPIでもエラーという概念をサポートしなければならないことが多い。文字列のインデックスを返すような文字列検索を考えてみてほしい。文字列が存在するなら結果の型はIntegerで、さもなければNothingだ。
 こんな計算をちまちまつなぎ合わせるのは退屈だ。Webページを解析する関数があるとしよう。HTMLページ、ページ内のボディ部、そのボディ内の最初の段落を取得したいとする。次のようなシグネイチャを持つ関数をコーディングする。

paragraph :: XmlDoc -> XmlDoc
・・・
body :: XmlDoc -> XmlDoc
・・・
html :: XmlDoc -> XmlDoc
・・・

 これらのシグネイチャから、次のような関数を書くことが出来る。

 paragraph body (html doc)

 問題は、paragraph, body, 及びhtmlの各関数はエラーになる可能性があるため、Nothingとなる型を許す必要があるという点だ。Haskellはそのような型としてJustが用意されている。Just xを使うと、Nothing、または特定の型を次のようにラップできる。

Prelude> Just "some string"
Just "some string"
Prelude> Just Nothing
Just Nothing
Prelude> 

 Justは、パターンマッチングを用いて除去出来る。先ほどの例に戻ると、paragraph, body, htmoの各ドキュメントは、Just XmlDocを返す可能性がある。あとは、Haskellのcase文(Erlamgのcase文と同じ)と同じパターンマッチングを使って、次のように書くことが出来る。

case (html doc) of
    Nothing -> Nothing
    Just x -> case body of
        Nothing -> Nothing
        Just y -> paragraph 2 y

 しかし、この結果は全く満足のいくものではない。我々は、paragraph 2 body(html doc)というようなコードが書きたかったのだから。我々が本当に必要としているのはMaybeモナドだ。次にその定義を示す。

data Maybe a = Nothing | Just a

instance Monad Maybe where
    return          = Just
    Nothing >>= f   = Nothing
    (Just x) >>= f = f x

 ここでラッピングしている型は、Maybe aという型構成子である。この型は、NothingまたはJust aをラップできる。
 returnは簡単だ。結果をJustでラップしているだけだ。バインドも簡単だ。Nothingの場合は、Nothingを返す関数を返す。Just xの場合は、xを返す関数を返す。どちらもreturnでラップされる。これで、これらの操作を簡単につなげることができる。
 Just someWebPage >>= html >>= body >>= paragraph >>= return
 以上で、要素を完全に組み合わせることが出来る。これが機能するのは、我々が合成する関数を介した意思決定をモナドが代行してくれるからである。

3日目で学んだこと

 1, 2日目から時間が経ってしまったのは、反省すべき点である。
 再帰的な型をユーザ定義で作れる点や初めて聞いたモナドなど面白そうなところがたくさんあった。
 ラップやアンラップについては、Swiftをいじった時にも出てきたのだがイマイチ理解ができていない。というかSwiftのOptinal型と同じような感じでいいのか!?

 ErlangとHaskellについて触れてみたが、個人的にはHaskellの方がいいな〜と感じたので次は通称「すごいH本」と呼ばれるすごいHaskellの本を呼んで勉強したい(10月以降になるかもしれないが。。。)
 個人的にHaskellの方がいいと思った理由の1つに自分の記事にコメントがついたり、ストック数が多かったりでコミュニティが活発な気がしたというのもあるので、この場でお礼を申し上げたい。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3