LoginSignup
19
26

ゼロからまなぶプログラミング原理 (副題: 初期値はnullか空の値か?)

Last updated at Posted at 2024-05-07

はじめに

クラスのメンバー変数にタイプヒントを使用しなかった過去のphpには、メンバー変数に直接初期化値を入れない限り、デフォルトでnullが初期値になりました。この傾向から、変数の初期値を空の値ではなくnullに設定するケースが多かったです。

class DefaultMember { public $variable; }
var_dump((new DefaultMember)->variable); // Null

タイプヒントが導入される以前の過去のphpでは、多くの場合、変数は単に値を保存するために使用されました。変数を宣言する前に、変数をどのように使用するかについての意図を考えて作成されるべきですが、タイプに対する意識なしに何かを作成する必要があるから変数には適当な値が入って、意図なしで変数が宣言されたので多くの場合初期値としてnullを使用するケースが多かったです。このような昔の慣行で、現在でもnullを初期値として使用するケースがあります。

phpでタイプヒントが登場し、変数にnullを使用するために?Tnull | Tなど(Tはあるタイプ)のnullable型を定義して変数を宣言する際、nullで初期化すべきかどうかについて悩んでいた人々が多かったと思います。今回のトピックは、これに対する答えを見つけるための目的で作成しました。多くのphp開発者に役立つ知識になることを願っています。

基本知識

phpでは、変数にさまざまな型の値を入れることができますが、多くの型を使用するほど、分岐処理が増えるからできるだけ少ない型を使用することをお勧めします。

変数に複数のタイプを使用する例

class CheckEntrance
{
    const ALLOWED_MIN_AGE = 5;

    public function __construct(
        private readonly null|int|string $age
    ) {
    	if(is_string($this->age) && !is_numeric($this->age)) throw new Error('required numeric string');
    }

    public function canEnter(): bool
    {
        if(is_string($this->age) && intval($this->age) >= self::ALLOWED_MIN_AGE) return true;
        if(is_int($this->age) && $this->age >= self::ALLOWED_MIN_AGE) return true;
        return false;
    }
}

var_dump((new CheckEntrance(age: 11))->canEnter()); // true
var_dump((new CheckEntrance(age: 4))->canEnter()); // false
var_dump((new CheckEntrance(age: '11'))->canEnter()); // true
var_dump((new CheckEntrance(age: '4'))->canEnter()); // false

変数に一つのタイプを使用する例

class CheckEntrance
{
    const ALLOWED_MIN_AGE = 5;

    public function __construct(
        private readonly int $age = 0
    ) {
    }

    public function canEnter(): bool
    {
        return $this->age >= self::ALLOWED_MIN_AGE;
    }
}

var_dump((new CheckEntrance(age: 11))->canEnter()); // true
var_dump((new CheckEntrance(age: 4))->canEnter()); // false
var_dump((new CheckEntrance(age: intval('11')))->canEnter()); // true
var_dump((new CheckEntrance(age: intval('4')))->canEnter()); // false

少ないタイプの使用

少ないタイプを使用すると、コードの可読性が高くなり、分岐処理の使用も減らすことができます。一般的に、1つのタイプまたは1つのタイプとnullを併用するタイプを主に使用します。(php8.2からはT?Tだけでなく、false | Ttrue | Tなどの構成も場合によって使用します。)この文書では、Tまたはnull | Tの場合のみ考慮することにします。

変数の初期値にnullを使用すべきだという主張

$varObj = new class { public int $var = 0; };
$varObj->var = 10;
var_dump($varObj->var); // 10

一つのタイプを使用すると、変数の初期値と割り当てられた値のタイプが一致します。例えば、初期値として$var = 0を初期値として割り当てたから同じタイプである$var = 10のという値を割り当てるという事です。nullが初期値の場合とは異なり、空の値は割り当てられた値の型と同じ型です。

$varObj = new class { public ?int $var = null; };
$varObj->var = 10;
var_dump($varObj->var); // 10

一方、nullを初期値として使用する場合、割り当てられた値は異なる型です。たとえば、引数のタイプヒントでintを指定した場合、nullを渡すと型不一致のためエラーが発生します。 nullを初期値として使用すると、型不一致によりエラーが発生するため、関数やメソッドのパラメーターとして、変数に値が割り当てられていない値を渡すときに、値が割り当てられたかどうかをエラーで確認しやすいという利点があります。

nullを使用するよりも空の値を使用する理由

変数 === nullまたはis_null(変数)を使用することと、変数 === 0 変数 === '' 変数 === []を使用することはいずれにしても割り当てと未割り当てを区分するための分岐は同じ処理をすると思われます。空の値を割り当てることはロジックの分岐の複雑さを減らしてくれないと考えられます。

しかし、空の値の場合、if文の使用を減らすスタイルのコーディングを通じてロジックの分岐を減らすことができます。これを理解するために、まず数0の例を考えてみましょう。

本論

数 0

「0」に対する数「n」の演算

  • 0に任意値「n」を加えた場合、結果はnになる
  • 0に任意値「n」を引いた場合、結果はnになる
  • 0に任意値「n」を掛けた場合、結果は0になる
  • 0を任意値「n」で割った場合、結果は0になる
    0に対する乗算と除算を考えてみましょう。0に「n」を演算した場合、その結果は0と、どんな値「n」との演算は存在しなかったも同然です。

「n」に対する数「0」の演算

  • 任意値「n」に「0」を加えた場合、結果は「n」になる
  • 任意値「n」から0を引いた場合、結果はnになる
  • 任意値「n」を「0」で掛けた場合、結果は「0」になる
  • 任意値「n」を「0」で割った場合、結果は無限大になる
    任意値「n」に対する加算と減算を考えてみましょう。nに「0」を演算した場合、その結果は「n」と「0」の演算は存在しなかったも同然です。

関数で表現する

このような0の特性を数学の関数で表してみましょう。f(0, n) = 0またはg(0, n) = nとなります。2つの対象の演算の結果が1つの対象になる値であることがわかります。

処理の前後の状態が同じ

一般的に、プログラミング言語における状態とは値で表現されます。値は変数に保存されたり、関数の戻り値になったり、スカラー値そのまま表現されることもあります。ある処理をする前の値と処理後の値が同じであれば、それは同じ状態を持つことと同じです。

数字の0は、色んなの演算に対して処理前後の値が同じ特性を持ちます。ある値「x」に対して演算「f」の処理が「x」になるもの、つまりf(x) = xという方程式を満たす解を数学では不動点(fixed point)と呼びます。不動点の性質をプログラミングで考えてみましょう。

先ほど、「0」は数学的にいくつかの演算に対して f(0, n) = 0 または g(0, n) = nと表現されると述べました。 この場合、f(0, n) = 0f'(n) = 0または f"(0) = 0と表現できます。 g(0, n) = ng'(n) = nまたは g"(0) = nと表現されます。つまり、f(0, n) = 0の関係である場合、f'(n) = 0f"(0) = 0と表現され、g(0, n) = nの関係を持っている場合、g'(n) = ng"(0) = nと表現されます。これは結局、f(x) = xの形で不動点の形で表されます。

空の値

空の値も、0の場合のように何の影響も与えない値として使用するコードを作成することができます。空の値に対するロジックの処理前後の状態の違いがない場合、アルゴリズムは実際に実行されるにもかかわらず、まるで実行されていないかのような状態を持ちます。

値または変数「x、y」についてのアルゴリズム「f」があると仮定しましょう。f(x, y) = xの関係で、「x」を変数とし、「y」を値としてみましょう。すると、f'(x) = xを満たす関数f'を作成できます。アルゴリズムf'は、実行前後の結果が同じであるため、ロジック的には実行されないのと同じです。もちろんプログラムは実行されますが、実行前後の状態は同じです。

変数の初期値がnullの場合、if(is_null(x)) { f'(x) }のコードが実行されると、「x」がnullであるため、if文を通過できません。したがって、f'(x)は実行されませんが、if文のコードは進行します。初期値をnullに設定した場合、変数が割り当てられたかどうかを確認することと、初期値としてアルゴリズムに不動点を使用した場合、if文なしでf'(x)を実行した場合の結果が「x」になることのコードの実行前後の状態は、if文が実行されなくてもf'(x)が実行されても同じです。

プログラミングにおいて、空の値は多くの場合、数学での不動点の役割を果たし、または不動点の形にする役割をして、空の値を使用するとき、処理前後の状態が同じであるという特性を利用して、if文の使用を減らすコーディングができます。

空の値を利用して分岐を減らす例

文字列結合演算子ドット(.)

ドット(.)は文字列を結合する演算です。任意の文字列に対して空文字列をドット(.)で結合すると、その結果は以前の文字列と同じです。空文字列の.演算による結合は、処理前後の結果が同じ結果を得ます。

$var = "";
echo "hello".$var;

"hello".$varを一つの関数と見なしましょう。すると、f("hello", $var) = "hello"f(x, $var) = y)の形で、アルゴリズムfの不動点として$varは空文字列になる必要があります。(f(x, n) = xになる関数fになるために「n」である$var''にします。)

$var = null;
if(is_null($var)) {
    echo "hello";
} else {
    echo "hello".$var;
}

変数の初期値としてnullを与えるか、空文字列を与えるかに関係なく、出力結果はいずれも"hello"と同じです。しかし、空文字列を使用することでif文の使用を減らすことができることをわかります。

文字列置換関数

$search = "";
$subject = "subject";
echo str_replace($search, "_", $subject);

str_replace関数で、変数$searchに対応する文字列を見つけて_に置き換えるコードを実行するとします。

$searchが空文字列の場合、置換対象を見つけることができないため、置換対象がなく、結果は"subject"のままです。

上記のコードの str_replace($search, "_", $subject) 部分は、"_"$subjectが決められた値なので、$searchについて考えることができます。f("subject", $search) = "subject"f(x, $search) = y)という形で表現できます。この方程式が不動点を持つためには$searchが空文字列になる必要があります。(f(x, n) = xになる関数fになるために「n」である$search''にします。)

$search = null;
$subject = "subject";
if(is_null($search)) {
    echo $subject;
} else {
    echo str_replace($search, "_", $subject);
}

もし$searchに空の値ではなくnullを割り当てた場合、上記のように分岐処理が増えるでしょう。

スプレッド演算子

$oneToFive = [1, 2, 3, 4, 5];
var_dump([10, 11, 12, ...$oneToFive]);

[10, 11, 12, ...$oneToFive]は、[10, 11, 12]の配列内にスプレッド演算子形式の配列が含まれている場合、$oneToFive配列の要素が[10, 11, 12]の配列に含まれ、結果は[10, 11, 12, 1, 2, 3, 4, 5]となります。

空の配列をスプレッド演算子で配列に含めると、何も要素が追加されずに[10, 11, 12]の結果が得られます。

$arr = [];
var_dump([10, 11, 12, ...$arr]);

空の配列をスプレッド演算子で配列に含めると、何も要素が追加されずに[10, 11, 12]の結果が得られます。

上記のコードは以下のような擬似コードに表現できます。f([10, 11, 12], $arr) = [10, 11, 12]f(x, $arr) = y)この方程式が不動点を持つためには、$arrが空の配列になる必要があります。(f(x, n) = xとなる関数fを作るために、「n」である$arr[]にします。)

$arr = null;
if(is_null($arr)) {
    var_dump([10, 11, 12]);
} else {
    var_dump([10, 11, 12, ...$arr]);
}

もし $arr に空ではなく null を割り当てた場合、上記と同様に分岐処理が増えるでしょう。変数の初期値として空の配列を使用することで、if文の使用を減らすことができます。

配列関数 (array_walk)

array_walk(array|object &$array, callable $callback, mixed $arg = null): bool

配列そのものを変更したい場合は、&$arrayのように&参照記号を使用し、配列内の値のみを使用したい場合は$arrayのように参照記号を使用しません。

$arr = [];
array_walk($arr, function(mixed $e) {
    $e = gettype($e);
});

var_dump($arr);

この関数は戻り値がない関数です。元の値そのものを変更したり、元の値を利用して他の値を生成するものであり、与えられた配列が空の配列 [] の場合、与えられたコールバック関数を実行しません。そのため、結果は元のままです。したがって、空の配列 []array_walk 組み込み関数の実行を行っても、行わなくても結果は同じです。

上記のコードは、次のような擬似コードに変換できます。f($arr) = []f(x) = y) この方程式を満たす不動点は、$arrの値が[]になった場合です。(f(x) = xになる変数xの値は$arr = []になる際です。)

$arr = null;
if(is_array($arr)) {
    array_walk($arr, function(mixed $e) {
        $e = gettype($e);
    });
}

var_dump($arr);

もし空の配列ではなくnullを初期値として使用した場合、if文を追加する必要があります。

配列関数 (array_map)

配列を受け取り、コールバック関数で定義された方法に従って、配列の各要素を変更し、新しい配列を作成する関数です。

$arr = [];
$typeList = array_map(function(mixed $e) {
    return gettype($e);
}, $arr);

var_dump($typeList);

空の配列が与えられた場合、array_mapは各要素に対してコールバック関数を適用する必要がありますが、要素がないため、コールバック関数を適用せずに要素がない空の配列を返します。
上記のコードは、次の擬似コードで表現できます:f($arr) = $arrf(x) = y)この方程式が不動点を持つためには$arrの値が[]になった場合です。

$arr = null;
$typeList = [];
if(is_array($arr)) {
    $typeList = array_map(function(mixed $e) {
        return gettype($e);
    }, $arr);
};

var_dump($typeList);

もし空の配列ではなくnullを初期値として使用した場合、if文を追加する必要があります。

不動点が存在するアルゴリズム

すべてのコードにおいて、空の初期値はアルゴリズムの実行において不動点の役割をしません。コード(アルゴリズム)で使用する変数に対して不動点を生成するロジックを作成する必要があります。アルゴリズム・コード「f」と空の値「a」について(「a」は「x」の要素とした場合)、f(x) = xを満たす「a」値を空の値に作る必要があります。コードを作成する際には、意図的に空の値が不動点となるようなコードを作成することが重要です。ただし、空の値を不動点にするためのコードを作成するのは手間がかかります。すべてのコードをこのように作成する必要はなく、不動点を生成する観点が有用であると認識しておくだけで十分です。

不動点思考の注意点

上記の例では、不動点に関する例を2つの方法で説明しました。空の値を使用してその結果が空の値になる場合と、空の値とある値を使用してその結果がある値になる場合です。

空の値を使用してその結果が空の値になる場合は、空の値を方程式の不動点として使用したものです。y = f(x)x = f(x)を満たす変数「x」は空の値になります。

空の値とある値を使用してその結果がある値になる場合は、ある値を不動点とする形を作るために空の値を使用するケースです。y = f(x, n)で「n」を空の値として置いた場合、x = f(x, n)x = f'(x)を満たす形にすることです。

不動点の概念は、0の性質を説明するために導入されました。n x 0 = 0のような場合、y = f(x)の形で「x」がプログラミング言語で変数になる場合もありますが、n + 0 = nのような場合、f(n) = nの形で、nが不動点ではなく、不動点の方程式の形を作るために0が使われる場合もあるため、y = f(x)という擬似コードにおいて、「x」が常に不動点の変数ではないことに注意しましょう。

phpで空の値の有用性

あえて不動点を空の値にするコードを作らなくても、様々な文法やphpの多くの組み込み関数は空の値が伝達された時に空の値を不動点として活用する機能が多いです。 これにより、if文で初期値をチェックするロジックを大幅に減らすことができます。

しかし、特定の機能を担当するコードが複雑または長くなると、空の値を不動点として活用しているかどうかを把握するのが難しい場合があります。このような場合、内部の実装を確認する代わりに、if文を使用してコードを実行しないようにコーディングする方が良い場合もあります。

または、空の値を不動点とするアルゴリズムであるが、空の値を処理するためにリソースを減らしたい時も、if文で空の値かどうかをチェックし、コードを実行しないようにする方が良い場合もあります。

nullや空の値について再考する

nullを初期値として使用すると、タイプヒントの制約を受けるコードに値を割り当てる際に、エラーを通じて値が割り当てられたかどうかを簡単に確認できる利点があります。

nullを初期値として使用する場合、未割り当ての状態は空の値ではなくすべてnullで統一して、混乱を減らす必要があります。nullを初期値として使用するスタイルでは、空の値がある場合、空の値が未割り当てではなく割り当てであると見なす必要があります。例えば、「支払金額」という変数があるとします。nullは支払金額が設定されていないことを意味し、0は無料であるため、0円を支払っても構わないという意味になります。この場合、0は有効な値です。一方で、「購入数量」という変数があるとします。nullは購入していない状態を意味します。しかし、0も購入していない状態です。したがって、購入しない状態で0を使用するか、nullを使用するかは曖昧になります。したがって、nullを初期値として使用する場合、空の値を割り当てられた値と見なすことが混乱を減らすことができます。

購入していない状態で変数が持つことができる値が0とnullの2つの場合、if(!is_null($var) && $var !== 0)という条件式で変数が割り当てられた状態のみif文内部のコードが実行されるのは条件式が長くなります。長い条件式を避けたい場合は、emptyを使用しますが、emptyは変数の型を示さないため、個人的に好ましくないコーディングスタイルです。(phpは強い型付け言語ではないため、ほとんどの場合、IDEが変数の型を推論できないことがありのでコードに型情報を記述するコーディングスタイルが良いと思います。)

したがって、nullを初期値として割り当てる場合、nullが持つ意味と空の値が持つ意味が重複しないようにするため、意味の重複が起こる場合は空の値を使用せず、nullのみを使用するコーディングスタイルを採用することが良いです。問題はこれによってコードの長さが増加することです。たとえば、購入数量は0またはnullまたは未割り当ての意味になるため、未割り当てを一つの値(null)として使用するために、変数の値が0になった場合、その値をnullに変更する必要があります。これにより、ロジックの分岐が追加されます。

class AddToCart
{
    public function __construct(
        private int $price,
        private ?int $quantity = null
    ) {
        assert($price >= 0, 'price cannot be negative.');
        assert(is_null($quantity) || (is_int($quantity) && $quantity > 0), 'quantity cannot be negative or zero.');
    }

    public function addAmount(int $quantity): self
    {
        $result = (int) $this->quantity + $quantity;
        if($result === 0) {
            $this->quantity = null;
        } elseif ($result > 0) {
            $this->quantity = $result;
        } else {
            throw new Error('quantity cannot be negative.');
        }
        
        return $this;
    }

    public function discountPerItem(int $price): self
    {
        $result = $this->price - $price;
        if ($result >= 0) {
            $this->price = $result;
        } else {
            throw new Error('price cannot be negative.');
        }

        return $this;
    }

    public function totalPrice(): int
    {
    	if($this->canBuy()) {
            return $this->price * (int) $this->quantity;	
    	} else {
            throw new Error('cannot purchase it.');
    	}
    }
    
    private function canBuy(): bool
    {
        return is_int($this->quantity) && $this->quantity > 0;
    }
}

var_dump((new AddToCart(price: 1000, quantity: 3))->totalPrice()); // 3000
var_dump((new AddToCart(price: 1000, quantity: 3))->discountPerItem(200)->totalPrice()); // 2400
var_dump((new AddToCart(price: 0, quantity: 3))->totalPrice()); // 0
var_dump((new AddToCart(price: 1000, quantity: 0))->totalPrice()); // Error: cannot purchase it.
var_dump((new AddToCart(price: 1000, quantity: 3))->addAmount(-3)->totalPrice()); // Error: cannot purchase it.

手続き型のコードを作る際に変数にnullを初期値として入れるのとは異なり、オブジェクトのコンストラクタを利用して$priceを必ず受け取るようにしたのでnull型を除外しました。

$this->quantityの値を使うコードごとにis_int($this->quantity) && $this->quantity > 0という、整数であるかどうかを確認して数量が0でないかを確認するコードが入っています。もともと数量メンバが整数型でけで定義されたら$this->quantity > 0のコードだけで十分であったでしょう。

if($result === 0) { $this->quantity = null; }は数量の数値が0になった場合、有効な数量でない場合を0とnullの2つではなくnullに変えています。このようにnullに変えると、$this->quantityは初期値設定で負数にならないように設定し、addAmountメソッドでは負数にならないように設定したので、nullでなければ使用できる値であることがわかります。is_int($this->quantity) && $this->quantity > 0!is_null($this->quantity)だけを確認するだけで十分です。しかし、if($result === 0) { $this->quantity = null; }という直感的でない変更が入ります。

空の値

空の値を未割り当てとして使用すると、多くのphp機能を使用する際、if文がないコードを生成することができます。コードの分岐を減らすという利点があります。

上記のように、nullを未割り当てとして使用する場合、購入数量が0になった場合、nullに変更するのは直感的ではないようです。然りとて、nullと0を併用すると、if文の条件が増える問題があり、またはemptyを使用して変数の型がわかりにくくなるという問題が生じます。

未割り当ての値として空の値を使用すると、nullを考慮する必要がなくなるため、空の値かどうかを確認するif文を一つだけ確認すれば良いのでif($var !== 0)のように簡潔になります。また、購入数量が「n」であった場合から0になる場合など、割り当ての状態から未割り当ての状態に変換する部分でも、nullに変換する必要はなく、空の値を割り当てるだけで十分なので直感的に満足できます。

class AddToCart
{
    public function __construct(
        private int $price,
        private int $quantity = 0
    ) {
        assert($price >= 0, 'price cannot be negative.');
        assert($quantity >= 0, 'quantity cannot be negative.');
    }

    public function addAmount(int $quantity): self
    {
        $result = $this->quantity + $quantity;
        if ($result >= 0) {
            $this->quantity = $result;
        } else {
            throw new Error('quantity cannot be negative.');
        }
        
        return $this;
    }

    public function discountPerItem(int $price): self
    {
        $result = $this->price - $price;
        if ($result >= 0) {
            $this->price = $result;
        } else {
            throw new Error('price cannot be negative.');
        }

        return $this;
    }

    public function totalPrice(): int
    {
    	if($this->canBuy()) {
            return $this->price * $this->quantity;	
    	} else {
            throw new Error('cannot purchase it.');
    	}
    }
    
    private function canBuy(): bool
    {
        return $this->quantity > 0;
    }
}

var_dump((new AddToCart(price: 1000, quantity: 3))->totalPrice()); // 3000
var_dump((new AddToCart(price: 1000, quantity: 3))->discountPerItem(200)->totalPrice()); // 2400
var_dump((new AddToCart(price: 0, quantity: 3))->totalPrice()); // 0
var_dump((new AddToCart(price: 1000, quantity: 0))->totalPrice()); // Error: cannot purchase it.
var_dump((new AddToCart(price: 1000, quantity: 3))->addAmount(-3)->totalPrice()); // Error: cannot purchase it.

変数のタイプを減すことでロジックをもっと簡潔に表現できます。

静的型言語との比較

動的型付け言語が複数の型を変数に許容するのに対し、静的型付け言語では一つの変数に一つの型が使用されます。また、空の値と未割り当てを区別する場合もあり、その変数で使用頻度が低いか、まったく使用されない特定の値(sentinel value)を使用して割り当てと未割り当てを区別します。phpでも、他の言語との類似性を考慮して、未割り当ての値をnullではなく空の値として使用することが好ましいです。

組み込み関数のnull因子の制限

「組み込み関数のnullを許容しない引数にnullを渡すことは推奨されません」Deprecate passing null to non-nullable arguments of internal functionsというRFCを見ると、組み込み関数の引数にnullタイプを明示的に渡す仕様でなければ、null値を引数として使用できなくなると述べられています。

これまで、nullが許容されないパラメーターにnullが渡された場合、空の値に暗黙的な型変換が行われて使用されていました。php8では、phpの組み込み関数にnullを渡すと一部エラーまたは警告が発生し、php9ではエラーが発生します。その後は、組み込み関数に未割り当ての値を渡す前に、nullかどうかを確認するコードを使用する必要があります。

しかし、初期値を空の値にする場合、nullかどうかをif文で確認する必要がなく、先述の多くの組み込み関数の機能に対して空の値は処理前後の状態が同じなので、未割り当ての場合には実行しない別個のif文を使用する必要もありません。今後のphpの変化に合わせて、初期値としてnullを使用するよりも、基本的に空の値を初期値として使用し、購入価格が0の場合に無料で購入するみたいに、空の値が有効な値になる場合、nullを初期値として使用する方法を採用することで、多くの利点を享受できます。

結論

nullを未割り当てとして使用する場合、タイプヒントを介してnullが許容されていないコードにnullが渡された場合、型不一致エラーが発生し、割り当ての有無を簡単に確認できる利点があります。しかし、いくつかの曖昧な状況や分岐処理などの複雑さが発生するため、必ずしもnullを未割り当てとして使用するよりも、空の値を未割り当てとして使用して空の値が有効な変数の場合のみnullを未割り当てとして使用する方が良いでしょう。

19
26
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
19
26