LoginSignup
159
126

More than 1 year has passed since last update.

C++のパラメータパック基礎&パック展開テクニック

Last updated at Posted at 2017-03-12

パラメータパック

C++には可変長テンプレートという機能があります。
... を用いて任意長のパラメータを引数にできる機能です。

以下は、可変長テンプレートを使った関数テンプレートの例です。

template < typename ...Args >
void func( Args ...args );

このときの
Argsテンプレートパラメータパック といい、
args関数パラメータパック といいます。
以下ではこの2つを特に区別する必要がない文脈では単にパラメータパックと呼びます。

この関数に引数を渡すと、全ての引数から型推論が行われます。
Args は推論された型が格納されたパラメータパックになります。
args は引数が格納されたパラメータパックになります。

パック展開

パラメータパックのままでは何もできないです。
パラメータパックはパック展開して使います。

パック展開にも ... を使います。
例えば

template <typename... Args>
auto my_make_tuple(Args ...args)
 -> std::tuple<Args...> // テンプレートパラメータパックの展開
{
  return { args... }; // 関数パラメータパックの展開
}

のようにパラメータパックの後ろに ... をつけることで、カンマ区切りでパラメータパックが展開されます。

my_make_tuple(1, 2, 3.0);

のように呼び出せば

Args... = int, int, double

なので、戻り値型は

std::tuple<Args...> 👉 std::tuple<int, int, double>

になりますし

args... = 1, 2, 3.0

なので
return 文は

{ args... } 👉 { 1, 2, 3.0 }

のようになる。

これが パック展開(pack expansion) です。

可変長テンプレートとパック展開はこれだけの機能であり、特に難しいことはありません。

ちなみに、この ...ellipsis と呼びます。

パラメータパックに関する仕様

パラメータパックは0個以上のパラメータが含まれる

template < typename ...Args >
void func( Args ...args ) {}

int main(){
  func( 1, 3.0, 'a' ); // OK func<int,double,char>
  func(); // OK func<>
}

パラメータパックはテンプレート引数の最後に置く

パラメータパックはテンプレート引数の最後に記述されなければならない。

// OK
template < typename Head, typename... Tail>
struct Hoge {};

// コンパイルエラー!パラメータパックは最後に置かなければならない
template < typename... Init, typename Last>
struct Fuga {};

※注意:ここからちょっとだけ上級者向けの説明(読みとばし推奨)

関数テンプレートの場合はパラメータパックを推論させることができるのでテンプレートパラメータパックを最後に書かないこともできる。
要するに、関数の引数内で推論が一意に定まるように展開して全部推論させれば問題ない。

#include <tuple>
// OK
// 第1引数からパラメータパックTypesが推論される
template < typename ...Types, typename T >
void ok1(std::tuple<Types...>, T)
{}

// NG
// Argsの要素数をコンパイラが判断できない
// 引数の最後にパラメータパックを置かなければならない
template < typename ...Args, typename T >
void ng(Args..., T){}

// OK
// 引数の最後にパラメータパックが展開されているので可
template < typename ...Args, typename T >
void ok2(T, Args...){}

int main(){
  // OK
  // Args = [int,double,char], T = int
  // tuple<int,double,char>からパラメータパックが推論される
  ok1(std::make_tuple(1,1.0,'a'), 1);

  // 推論できません
  // ng1(1,2,3,4);

  // OK
  // T = int, Args = [int,int,int]
  ok2(1,2,3,4);
}

(敬虔なC++erならngの呼び出しの引数は1つが期待されていることを即座に判断できるでしょう)

※上級者向けの説明OWARI

関数パラメータパックに共通の修飾を付加できる

// テンプレートパラメータパックのパラメータすべてに
// const& を付加して
// パラメータパックArgsの全てのパラメータを、
// constな左辺値参照として受け取る
template < typename ...Args >
void f( const Args&... args ) {}

型推論の補助としての機能。
おそらく、以下のように完全転送に使うことが多いだろう。

template <typename ...Args>
void f( Args&&... args ) {
  hoge( std::forward<Args>(args)... ); // パラメータパックの拡張は後述
}

パラメータの要素数取得

sizeof...( ) にパラメータパックを指定すると要素数を取得できる。

template < typename... Types>
struct Hoge {
  static constexpr unsigned long long size = sizeof...(Types);
};

int main(){
  static_assert( Hoge<>::size == 0 , "" );
  static_assert( Hoge<void,void,void>::size ==3 , "" );
}

パラメータパックの拡張

パラメータパックに共通の処理を適用できる機能。
関数パラメータパックのパラメータ全部に同じ関数を適用したり、
テンプレートパラメータパックにメタ関数を適用したりできる。

関数パラメータパックの拡張の例

#include <iostream>

template < typename T >
T g(T a){ return a*a; }

template < typename... Args >
auto print( Args const&... args ){
  for(auto const& e : { args... })
    std::cout << e << std::endl;
}
template < typename... Args >
void f(Args... args){
  print( g(args)... ); // 全てのパラメータにgを適用してprintに渡す
}

int main(){
  f(1,2,3,4);
  // output:
  // 1
  // 4
  // 9
  // 16
}

テンプレートパラメータパックの拡張の例

template < typename...Types >
std::tuple<std::decay_t<Types>...> // 全ての型にstd::decay_tを適用
make_tuple(Types&&... args){
  return std::tuple<std::decay_t<Types>...>{ std::forward<Types>(args)... };
}

パラメータパックが宣言できる場所

パラメータパックが宣言できる場所は以下の3つ!

  • 関数のパラメータ
  • テンプレートパラメータ
  • テンプレートテンプレートパラメータ

関数のパラメータで関数パラメータパックの宣言

template < class... Args >
void f(Args... args); // <- ここ、パラメータパックはargs

テンプレートパラメータでパラメータパックの宣言

template < class... Args > // <- もちろんここ、パラメータパックはArgs
struct Hoge {};

テンプレートテンプレートパラメータでパラメータパックの宣言

template < template < class... > class T > // <- こういうやつです
struct Hoge {};

パック展開ができる場所

以下の場所でパラメータパックを展開できる

関数の引数(関数パラメータパックの展開)

f(args...);

テンプレートの引数(テンプレートパラメータパックの展開)

std::tuple<Args...> tup;

初期化子(関数パラメータパックの展開)

int arr[] = { args... };

継承の基底クラスリスト(テンプレートパラメータパックの展開)

template < class... Bases >
class Derived : Bases...; // <- ここ

コンストラクタのメンバ初期化子(関数パラメータパックの展開)

template < class... Bases >
class Derived : Bases... {
  Derived(Bases... bases)
    : Bases(bases)... {} // <- ここ
};

ラムダキャプチャ

[](auto... args){
    return [args...](auto ){
//          ^~~~~~~ここ
    };
};

using宣言(since C++17)

template <typename... T>
struct A : T... {
  using T::operator()...; // 受け取ったクラスのoperator()を全て使えるようにする
};

動的例外指定

忘れて下さい

Fold Expression

C++17で入ったFold Expressionを使えばいつでもどこでもパック展開できる。

template < class... Pack >
void func(Pack... pack){
    // ...
}

のような、パックがあったとして
Fold Expressionで展開する方法はおおよそ次の種類がある。
Fold Expressionは()で囲まれている必要がある。
opは二項演算子である。

( pack op ... )             //(1)
( ... op pack )             //(2)
( pack op ... op init )     //(3)
( init op ... op pack )     //(4)

(1)は右畳み込みで

(E + ...)

と書くと

E_1 + (... + (E_{N-1} + E_N))

のように展開される。

(2)は左畳み込み

(... + E)

と書くと

(((E_1 + E_2) + E_3 ) + ... ) + E_n

のように展開される。

(3)と(4)はそれぞれ(1)と(2)に初期値を与えたバージョンである。

これを使えば
C++14ままで再帰を使うとこう書いたコード

auto old_sum1(){
    return 0;
}

template<typename T1, typename... T>
auto old_sum1(T1 s, T... ts){
    return s + old_sum1(ts...);
}

C++14までパック展開を使うとこう書いたコード

template < typename T, typename... Tail >
constexpr T old_sum2( T&& head, Tail&&... tail ){
  T result = head;
  using swallow = std::initializer_list<int>;
  (void)swallow{ (void(result += tail),0)... };
  return result;
}

などは
以下のように簡潔に書ける。

template<typename... T>
auto fold_sum_1(T... s){
    return (... + s);
}

または初期値付きで
つぎように書くことができる。

template<typename... T>
auto fold_sum_2(T... s){
    return (1 + ... + s);
}

使用可能な二項演算子は決まっていて
以下が使える。
要するにオーバーロードしたらなんでもできます。
どんどんやっていきましょうやったらclang-6が壊れました、やりすぎ注意)。

+ - * / % ^ & | = < > << >> += -= *= /= %= ^= &= |= <<= >>= == != <= >= && || , .* ->*

再帰によるパックの逐次展開

いくらパラメータパックが展開できても値にアクセスできなければ、何の計算もできない。
パラメータパックを逐次処理する一つの方法が再帰である。

template < typename T >
T max( T const& a, T const& b ){
  return a < b ? b : a;
}
template < typename Head, typename... Tail >
auto max( Head&& head, Tail&&... tail ){
  return max( std::forward<Head>(head), max( std::forward<Tail>(tail...) ) );
}

このように再帰で先頭の値を逐次的に取り出す方法が有効である。

initializer_listという選択肢

しかしながら、maxの引数は全部同じ型が要求されるはずであるので、
initializer_listで受け取る方がよい設計のように思われる。
実際、STLのmin/maxの可変長引数版はそうなっている。

つまりこんな感じに

#include <initializer_list>
#include <algorithm>

namespace cranberries {

template < typename T >
T max( std::initializer_list<T> il ){
  return *std::max_element(il.begin(), il.end());
}

} // ! namespace cranberries

int main(){
  cranberries::max({1,2,3,4,5,6});
  std::max({1,2,3,4,5,6});
  // 可変長はないよ
  //std::max(1,2,3,4,5,6);
}

パック展開とパラメータパックの拡張による展開

パラメータパックを全部出力したいという場合について考える。

template < class... Args >
void print_all(Args&&... args){
  // 実装
}

という可変長テンプレートの関数で「実装」の部分でどうやって引数を展開するかという問題。

パラメータパックの拡張が使えそうである。
つまり、

template < typename T >
void print(T const& a){
  std::cout << a << std::endl;
}

という関数を用意しておき、

print(args)...

のようにパラメータパックを拡張して展開すればよいのではないか?
そうすると

print_all(1,2,3);

print(1), print(2), print(3)

のように展開される。

問題はどこにパック展開するかである。

関数パラメータパックが展開できるのは2箇所だけ

  • 関数のパラメータ
  • 初期化子

どちらかしかない。

関数のパラメータに展開するには重大な問題がある。
C++14現在では関数のパラメータの評価順序が決まっていないのである。
表示順を左からに固定するために初期化子に展開する。

Swallow

初期化子として配列を使ってもいいが、
配列はスタックメモリを使うので、initializer_listでやります。
なんでもいいです!

みたいな型があると初期化子になんでも突っ込めるので便利かもくらい。

OSSのコードを読むと
swallow という型別名が使われているようです。
既にIdiomっぽいので自分もこれに乗っかっていきます。

swallow 自体に意味がないですよということを示すためにvoidにキャストしています(これがないと戻り値がDiscardされるのでコンパイラが警告を出します)。
初期化子にパラメータパックを拡張して展開します。
このとき、初期化子リストは全部int型になってもらわないと困るので、
カンマ演算子 を使います。

まとめると

initializer_listの中にパック展開する

初期化子の中で以下のように拡張してパック展開

(void(適用したい関数(args)),0)... 

となります。

コード例です

#include <iostream>
#include <initializer_list>

template < typename T >
void print(T const& a){
  std::cout << a << std::endl;
}

template < typename... Args >
void print_all(Args... args){
  using swallow = std::initializer_list<int>;
  (void)swallow{ (void( print(args) ), 0)... };
}

int main(){
  print_all(1,2,3,4,5,6);
}

この方法を使えば上記のmaxも以下のように書けますね。

#include <initializer_list>
#include <utility>
#include <type_traits>

namespace cranberries {


template < typename T, typename... Tail >
constexpr std::decay_t<T> max( T&& head, Tail&&... tail ){
  std::decay_t<T> result = head;
  using swallow = std::initializer_list<int>;
  (void)swallow{ (void(result = result < tail ? tail : result),0)... };
  return result;
}

} // ! namespace cranberries

int main(){
  constexpr auto x = cranberries::max(1,2,3,4,5);
  static_assert(x==5,"");
}

Index Tuple Idiom (The Indices Trick)

tupleの要素をカンマ区切りで関数に渡したい場合とか、
コンパイル時に展開するときによく使うやつ。

コンパイル時整数シーケンス(since C++14)

ずばりコンパイル時に整数シーケンスを生成する型として用意された型がある。
utilityヘッダに定義されている、integer_sequenceとその型別名であるindex_sequenceだ。
<0,1,2,3,4,5,6>のような整数シーケンスをパラメータに持つクラステンプレートである。

namespace std {
  template <class T, T... I>
  struct integer_sequence {
    using value_type = T;
    static constexpr size_t size() noexcept { return sizeof...(I); }
  };
}

namespace std {
  template <size_t... I>
  using index_sequence = integer_sequence<size_t, I...>;
}

実はこれを使えば簡単にtupleを展開できるという優れものである。

実際にやりましょう

make_index_sequence< N > もしくは index_sequence_for< Args... >
をつかいます

namespace std {
  template <class T, T N>
  using make_integer_sequence = integer_sequence<T, 0, 1, , N - 1>;
}

namespace std {
  template <size_t N>
  using make_index_sequence = make_integer_sequence<size_t, N>;
}

namespace std {
  template <class... T>
  using index_sequence_for = make_index_sequence<sizeof...(T)>;
}

のようになっていて、コンパイル時整数シーケンスを生成するときにはこれらいずれかを使います。

実際のコードを見ていきましょう。
tupleの要素を全て出力する関数tuple_printを書いていきますます。

#include <array>
#include <tuple>
#include <utility>
#include <iostream>

namespace cranberries {
namespace cranberries_magic {

template < typename Tuple, size_t ...I >
std::array<int,sizeof...(I)>
tuple_print_impl(Tuple&& t, std::index_sequence<I...>){
  return {{ (void( std::cout << std::get<I>(t) << std::endl ), 0)... }};
}

} // ! namespace cranberries_magic

template < typename Tuple >
void tuple_print(Tuple&& t){
  cranberries_magic::tuple_print_impl(std::forward<Tuple>(t),
    std::make_index_sequence<std::tuple_size<std::decay_t<Tuple>>::value>{});
}

} // ! namespace cranberries

int main(){
  cranberries::tuple_print(std::make_tuple(1,2,3,4,5));
}

まず、整数シーケンスを推論する必要性から実装が分かれます。
ユーザーから見える関数tuple_printでは、tupleをうけとり整数シーケンスとともにtuple_print_implに渡します。

(Tuple&& t)でまるごと推論しているのは完全転送するためですが、瑣末ごとです。
そして、tuple_print_implを呼びます、このとき
std::make_index_sequence<std::tuple_size<std::decay_t<Tuple>>::value>{}
で整数シーケンスを作って渡します。

tuple_print_implでtupleを展開するために整数シーケンスが必要なのでsize_tのパラメータパックを宣言しておきます。
template < typename Tuple, size_t ...I >
パラメータパックはindex_sequenceから推論します。
tuple_print_impl(Tuple&& t, std::index_sequence<I...>)
最後に、swallowで説明したように、初期化子でパラメータパックの拡張を用いてパック展開すればいいです。このときに使うのがtupleの要素にインデックスでアクセスできるstd::getです。
using swallowとかいちいち書くのが面倒になってきたのでstd::arrayを戻り値にしてreturn文で初期化子をいきなり書いています。

二分再帰

整数シーケンスを使って二分再帰をする方法を示します。
方法としてはパラメータパックの分割と再帰による分割統治法になります。
(コンパイル時に)可変長の引数を集計する関数sumを二分再帰させてみました、ご査収ください。

#include <array>
#include <utility>

namespace cranberries {

namespace cranberries_magic {
    template < size_t, class T = void >
    struct left{
        using type = T;
    };
    template < size_t N, class T >
    using left_t = typename left<N,T>::type;

    template < class >
    struct sum_impl;

    template < size_t... Indeces >
    struct sum_impl<std::index_sequence<Indeces...>> {
        template < typename T, typename... Args2 >
        static constexpr T eval( left_t<Indeces,T>... args1, Args2... args2){
            return sum_impl<std::make_index_sequence<sizeof...(args1)/2>>::template eval<T>(args1...)
                 + sum_impl<std::make_index_sequence<sizeof...(args2)/2>>::template eval<T>(args2...);
        }
    };

    template <>
    struct sum_impl<std::index_sequence<>> {
        template < typename T, typename... Args2 >
        static constexpr T eval( T arg ){
            return arg;
        }
    };
} // ! namespace cranberries_magic

    template < typename Head, typename... Tail >
    constexpr auto sum(Head head, Tail... tail){
        return cranberries_magic::sum_impl<
            std::make_index_sequence<(sizeof...(Tail)+1)/2>
            >:: template eval<std::decay_t<Head>>(head, tail...);
    }

} // ! namespace cranberries

int main(){
  constexpr auto x = cranberries::sum(1,2,3,4,5,6,7,8,9,10,11); // 66
  static_assert(x==66,"");
}

説明します。

まず、sumに渡されたパラメータパックの要素分割方法です。

template < size_t, class T=void >
struct left{
  using type = T;
};

というクラスを使って左側の引数リストを展開して右側を推論します。

std::make_index_sequence< sizeof...(args)/2 >
のような引数の数の半分のだけ整数シーケンスを作り、sum_implクラスのテンプレート引数に渡します。

そして、sum_implクラスのeval関数に渡しますがこのときに型Tを渡し、
Tを整数シーケンスで引数の左側に展開します。
残りの右側を可変長テンプレートで推論します。

template < typename T, typename... Args2 >
static constexpr T eval( left_t<Indeces,T>... args1, Args2... args2)
// ↑ この部分。引数左側にTが展開されて残りを推論する

これを最終的に要素が一つになるまで繰り返します。
要素が1つになれば、std::index_sequence<sizeof...(args...)/2>
がゼロになりテンプレートパラメータパックが空になるので、
その場合に値をそのまま返すようにクラスの完全な特殊化を定義して再帰を止めればいいです。

template <>
struct sum_impl<std::index_sequence<>> {
    template < typename T, typename... Args2 >
    static constexpr T eval( T arg ){
        return arg;
    }
};

複数のパラメータパックを渡したいとき(追記:2017-03-15)

次のようなことがしたい人がいるかもしれない。
関数に2つ以上のパラメータパックを渡したいというものだ。
しかし、パラメータパックの区切りが不明なためにコンパイルエラーとなるのは明らかだ。

// NG
// 引数のどこまでがLeftでどこからRightなのかわからない
template < typename... Left, typename... Right >
void ng2(Left... left, Right... right){
  f(left...);
  g(right...);
}

このようなことがしたい場合、tupleとして引数を完全転送することで解決する。
tupleの要素を関数にカンマ区切りで渡すためにIndex Tuple idiomを使ってapply関数を用意する(C++17に採択が決定している機能です)。

#include <tuple>
#include <utility>
#include <iostream>
#include <initializer_list>

namespace cranberries {
namespace cranberries_magic {

template <class F, class Tuple, std::size_t... I>
constexpr decltype(auto) apply_impl( F&& f, Tuple&& t, std::index_sequence<I...> )
{
  return f(std::get<I>(t)...);
}

} // ! namespace cranberries_magic

template <class F, class Tuple>
constexpr decltype(auto) apply(F&& f, Tuple&& t)
{
  return cranberries_magic::apply_impl(std::forward<F>(f), std::forward<Tuple>(t),
    std::make_index_sequence<std::tuple_size<std::decay_t<Tuple>>::value>{});
}

} // ! namespace cranberries

template < typename T >
void print(T const& a){
  std::cout << " " <<  a;
}

template < typename... Args >
void f(Args... args) {
  std::cout << "called f with :";
  using swallow = std::initializer_list<int>;
  (void)swallow{ (void( print(args) ), 0)... };
  std::cout << std::endl;
}
template < typename... Args >
void g(Args... args) {
  std::cout << "called g with :";
  using swallow = std::initializer_list<int>;
  (void)swallow{ (void( print(args) ), 0)... };
  std::cout << std::endl;
}

// OK
// 引数のどこまでがLeft,どこからRightなのかが
// コンパイラにもわかるように
// tupleに固めて区切る
template < typename... Left, typename... Right >
void ok( std::tuple<Left...> largs, std::tuple<Right...> rargs){
  // tupleの要素を関数に展開するためにapply関数をつかう
  // もっとちゃんとしたものがC++17に採択決定している
  cranberries::apply([](auto... args){ f(args...); }, largs);
  cranberries::apply([](auto... args){ g(args...); }, rargs);
}

int main(){
    // forward_as_tupleで引数を完全転送して優勝!
    ok( std::forward_as_tuple( 1,2,3 ),
        std::forward_as_tuple( 4,5,6 ) );
}

実行結果 | Wandbox

この手法はstd::pairpiecewise constructionに使われる手法である。
std::pairのコンストラクタは第1引数にstd::piecewise_construct_tをとるものがある。
第1引数にstd::piecewise_constructを指定すると、第2,3引数のtupleの要素をfirst,secondのコンストラクタに完全転送してpairを構築してくれる。


#include <map>
#include <utility>
#include <tuple>

struct Point3D {
  Point3D(int a, int b,int c)
    : x{a}, y{b}, z{c} {}
  int x;
  int y;
  int z;
};

int main(){
  std::pair<Point3D, int> p1{ std::piecewise_construct,
    std::forward_as_tuple( 1,2,3 ),
    std::forward_as_tuple( 1 )
  };
}

なにに使うのかというと。
主にstd::mapemplaceである。
map<Key,Value>に直接pairの初期化子を転送する場合に、引数のどこまでがKeyの初期化子でどこからがValueの初期化子なのかがわからなくなるためこのうような方法が用意されているのだ。

ちなみにこのコードでPoint3DをmapのKeyに指定したことでPoint3Dの比較演算子を実装しなければならない。
このような場合にstd::forward_as_tupleはstd::tieの代わりとしても有用なので覚えておいて損はないと思われる。

#include <map>
#include <utility>
#include <tuple>

class Point3D {
public:
  Point3D(int a, int b,int c)
    : x_{a}, y_{b}, z_{c} {}
  int x() const { return x_; }
  int y() const { return y_; }
  int z() const { return z_; }
private:
  int x_;
  int y_;
  int z_;
};

bool operator <(const Point3D& a, const Point3D& b){
  return std::forward_as_tuple(a.x(), a.y(), a.z()) < std::forward_as_tuple(b.x(), b.y(), b.z());
}
int main(){
  std::map<Point3D, int> hash{};

  hash.emplace(std::piecewise_construct,
    std::forward_as_tuple( 1,2,3 ),
    std::forward_as_tuple( 1 )
  );
}

パラメータパックを逆順に展開する(追記:2017-9-01)

こんなことをしたい人がいるのかわかりませんが。

reverse_apply(f, make_tuple(1,2,3,4));

のようにしたとき、tupleの要素を反転してfに適用するという場合を考えます。

#include <tuple>
#include <type_traits>
#include <utility>
#include <initializer_list>
#include <iostream>

namespace cranberries {
namespace cranberries_magic {

template < class F
         , class Tuple
         , size_t... Indices
         , size_t Last = std::tuple_size_v<std::decay_t<Tuple>>-1 >
constexpr decltype(auto)
reverse_apply_impl(F&& f, Tuple&& t, std::index_sequence<Indices...>){
    return f(std::get<Last-Indices>(t)...);
}

}

template < class F
         , class Tuple >
constexpr decltype(auto)
reverse_apply(F&& f, Tuple&& t){
    return cranberries_magic::reverse_apply_impl(std::forward<F>(f)
                                               , std::forward<Tuple>(t)
                                               , std::make_index_sequence<std::tuple_size_v<std::decay_t<Tuple>>>{});
}

}

int main(){
    auto println = [](auto&& head, auto&&... tail) -> void {
        std::cout << head;
        (void)std::initializer_list<int>{
            (void(std::cout << " " << tail), 0)...
        };
        std::cout << std::endl;
    };

    std::apply(println, std::make_tuple(1,2,3,"4"));
    cranberries::reverse_apply(println, std::make_tuple(1,2,3,"4"));
}

tupleの要素を展開するとき
tuple = [1,2,3,4]
のような場合を考えると

std::get<3>(tuple), std::get<2>(tuple), std::get<1>(tuple), std::get<0>(tuple)

となればいいわけです。

最初のgetは tupleの要素数-1 で、そこから減らしていけばいいのです。

159
126
9

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
159
126