Help us understand the problem. What is going on with this article?

このPHPがテンプレートエンジンのくせに慎重すぎる (中篇)

この記事について

みなさんここまでついてこられていらっしゃいますでしょうか。この記事はこのPHPがテンプレートエンジンのくせに慎重すぎる (前篇)の続きなので、先に読んで、できれば実際に準備してから来てください。

一気に後篇を書いてしまおうとも思ったのですが、前回は徹夜して書いたので本日は著しく眠いので「Psalmの続き」と「型宣言」と「Phan」の話を書いて私は寝ます。

Psalm/Psalter (前回の続き)

さて、PsalterはPsalm同梱の修正ツールです。./vendor/bin/psalter --issues=allで実行するとプロジェクト内のファイルが書き換えられます。

Psalterに限らず、今後ソースコードを自動で書き換えるツールがありますので、実行前にGitでコミットしておいてください。

diff --git a/src/Hello.php b/src/Hello.php
index 4b825af..5a0aced 100644
--- a/src/Hello.php
+++ b/src/Hello.php
@@ -4,7 +4,7 @@ namespace Bag2\Hello;

 final class Hello
 {
-    public function to($name)
+    public function to($name): string
     {
         return "Hello, {$name}!";
     }

このコードを見るとreturn "Hello, {$name}!"と書いてあるので、このHello::to()は絶対に文字列を返すということがわかります。なので、足りなかった戻り値の型宣言 : stringを付けるのは確実に安全です。では、引数に型宣言を付けてstring $nameにするのはどうでしょうか。この操作は実際のところあまり安全ではありません。

以下のようなテストを加えてみましょう。

diff --git a/tests/HelloTest.php b/tests/HelloTest.php
index a1e489f..fb9baa9 100644
--- a/tests/HelloTest.php
+++ b/tests/HelloTest.php
@@ -12,5 +12,9 @@ final class HelloTest extends TestCase

         $this->assertSame('Hello, World!', $subject->to('World'));
         $this->assertSame('Hello, Miku!', $subject->to('Miku'));
+        $this->assertSame('Hello, 1!', $subject->to(1));
+        $this->assertSame('Hello, 1.0!', $subject->to(1.0));
+        $this->assertSame('Hello, true!', $subject->to(true));
+        $this->assertSame('Hello, Array!', $subject->to([]));
     }
 }

この例は雑で、当てずっぽうで非常に書いたものです。

なので、PHPUnitを実行してみると、まあ当然失敗します。

スクリーンショット 2020-01-29 20.39.04.png

このエラー出力がら読み取れるのは、$subject->to(1.0) === 'Hello, 1.0!'になると思ったら'Hello, 1!'だったということです。アサーションの順番を並べ変えると、以下のような出力も確認できます。

スクリーンショット 2020-01-29 20.48.01.png

真偽値のtrueは文字列として埋め込むと1になってしまうのです。

ここで私たちは一つの決断を迫られれています。bool(true)string("true")に変換したり、float(1.0)string("1.0")に変換する処理を追加してまで"Hello, {$name}!"に埋め込む必要はあるのでしょうか?

もちろんそれが役に立つ場面もありますが、「元気良く英語であいさつする!」というこのクラスの責務(たったいま考えました)にとっては、任意の型を値を文字列に変換するなんて機能は蛇足です。なので、割り切ってstring $nameにするということにしましょう。

Hello::to()メソッドはstring $nameという型宣言を追加することで「引数が数字だったら…」「引数が配列だったら…」「引数がオブジェクトだったら…」という可能性を全部捨てて、「$nameは文字列である」という大前提を元に処理を書けるようになります。これは自動修正ではなく、人間が自分の意思で決めることです。

ということで型をつけてみましょう。

diff --git a/src/Hello.php b/src/Hello.php
index 5a0aced..4caeb43 100644
--- a/src/Hello.php
+++ b/src/Hello.php
@@ -1,10 +1,12 @@
 <?php

+declare(strict_types=1);
+
 namespace Bag2\Hello;

 final class Hello
 {
-    public function to($name): string
+    public function to(string $name): string
     {
         return "Hello, {$name}!";
     }

PHP7ではdeclare(strict_types=1);を付けることで「強い型付け」が有効になります。この状態では引数に間違った型の値を渡して呼ぶとTypeErrorが発生します。つまり、予期せぬ状態のまま動き続けることを防げるのです。

ここで気をつけてほしいのは、「declare(strict_types=1);はファイル単位で機能すること」「メソッド(関数)型宣言の型の強弱は呼び出し側の設定に依存すること」です。

それはどういうことでしょうか。先ほどのHello::to()メソッドに数字を渡したテストはまだ残っていますので、PHPUnitを動かして確認してみましょう。

int(1)が一番上に来るように、また並び換えて実行してみます。

diff --git a/tests/HelloTest.php b/tests/HelloTest.php
index a1e489f..875388d 100644
--- a/tests/HelloTest.php
+++ b/tests/HelloTest.php
@@ -12,5 +12,9 @@ final class HelloTest extends TestCase

         $this->assertSame('Hello, World!', $subject->to('World'));
         $this->assertSame('Hello, Miku!', $subject->to('Miku'));
+        $this->assertSame('Hello, 1!', $subject->to(1));
+        $this->assertSame('Hello, 1.0!', $subject->to(1.0));
+        $this->assertSame('Hello, true!', $subject->to(true));
+        $this->assertSame('Hello, Array!', $subject->to([]));
     }
 }

結果はどうなるでしょうか。

スクリーンショット 2020-01-29 21.13.06.png

この行でのアサーション失敗が報告されるということは$this->assertSame('Hello, 1!', $subject->to(1))は通過してしまっているのです。つまり、強い型付けは機能していません。強い型付けを有効化するには、メソッドの呼び出し側にもdeclare(strict_types=1);を設定してあげる必要があります。

diff --git a/tests/HelloTest.php b/tests/HelloTest.php
index a1e489f..4bb05f9 100644
--- a/tests/HelloTest.php
+++ b/tests/HelloTest.php
@@ -1,5 +1,7 @@
 <?php

+declare(strict_types=1);
+
 namespace Bag2\Hello;

 use Bag2\Hello\TestCase;

./vendor/bin/phpunitを再実行します。

スクリーンショット 2020-01-29 21.16.12.png

TypeErrorになりました。これで$subject->to(1)は防げているということがわかります。PHPUnitでは「メソッド呼び出しが発生したらTypeErrorが発生する」ということをテストすることもできるのですが、型宣言を設定したあらゆる箇所に「(文字列以外の型)を渡したらTypeErrorが発生する」ということをテストしても仕方ないので、これは消してしまって良いでしょう。

ということで、$subject->to(true)$subject->to(1.0)などのテストは全部消してしまって構いません。最終的にこうなりました。

tests/HelloTest.php
<?php

declare(strict_types=1);

namespace Bag2\Hello;

use Bag2\Hello\TestCase;

final class HelloTest extends TestCase
{
    public function test()
    {
        $subject = new Hello();

        $this->assertSame('Hello, World!', $subject->to('World'));
        $this->assertSame('Hello, Miku!', $subject->to('Miku'));
    }
}

Phanをインストールしよう

Phanも強力な静的解析ツールのひとつです。もともとはPHPの最初の開発者であるRasmus Lerdorfが所属していたEtsy社で開発されていましたが、現在はPhanの開発コミュニティに移管されています。

インストールにはいままでと同じようにcomposer-bin-pluginを利用します。

$ composer bin phan require phan/phan

実行方法もいままでと同様ですが./vendor/bin/phan --initで設定ファイルを生成することができます。.phan/config.phpが生成されるので、これもGitでコミットしておきましょう。ここまできたら手慣れたものですね。

間違いなければ、たぶん何も警告は出ないと思います。

phan-no-error.gif

Phanは前に紹介したPHPStan・Psalmと何が違うのでしょうか。なぜ三種類もインストールしたのでしょうか。細かいことを言うと解析の方式が違うというのがあるのですが、正直なところ、こんな小さな規模のパッケージでは、できることは概ね一緒です。

極端なことを言うと、どれか一種類だけを入れても用は足ります。が、この一連の記事は「慎重すぎる」ことが旨ですので、石橋を叩いて渡るがごとく、三種類を全部使っていきます

これは別にねたでいっているわけではなくて、ここまで紹介した三種類の静的解析ツール(Phan, PHPStan, Psalm)はどれも精力的に開発されており、毎週のように新しいバージョンがリリースされています。細部では三種類それぞれ異なる方式で検査しているので、どれか一つでしか検出されない問題というのもあります。

品質を高く保とうと思えば、一から書きはじめるライブラリでは三種類の静的解析の全部で解析をするというのは別段法外にコストが高いということもないでしょう。ただ、実装の行数が増えていって簡単に解決できない問題を指摘されたり無益な項目ばかり指摘されるなと思ったらツールを減らしていくという判断もアリです。

ここで注意してほしいのは「三種類全部入れる」というのは新規で書き始めるプロジェクトの話であって、既存のプロジェクトで静的解析で指摘される項目を全部潰すというのは気合を入れて直していかないと、かなり大変です。まして三種類のツール全部で通すなら法外にコストが掛かることになりかねません。過ぎたるは及ばざるが如しと言います。静的解析に適合するように修正したつもりが、バグを埋め込んでしまうということになりかねません。

静的解析というものは「コードを実行してないけど危なそうだよ」という技術であって、ユニットテストのような手段であっても「実行したコードが意図した通りに動いている」という事実の方が常に優先されます(ただしテストコードが意図を表現できず間違っているということはありえます)

いろんな時間にあいさつしよう!

いままで実装してきたのはHelloと言うしか能のない単細胞野郎です。実際には日本語には時間帯ごとに豊富な挨拶表現があります。なので、時間帯ごとに違った挨拶ができるようにプログラミングしていきましょう。

俺はプロのプログラマだからな、挨拶くらい楽勝だぜ!

src/Aisatsu.php
<?php

declare(strict_types=1);

namespace Bag2\Hello;

final class Aisatsu
{
    public function byHour(int $hour): string
    {
        if (5 < $hour && $hour < 10) {
            return "おはようございま!";
        }

        if (10 < $hour && $hour < 16) {
            return "こんにちはこんにちは!";
        }

        return "こんばんは!";
    }
}

楽勝ですな。


まとめ

以下のツールについて紹介しました。

  • Psalm/Psalter (前回の続き)
  • Phan

前回に比べるとちょっと内容が薄いですね… まだ続きます。

感想

夜は寝ましょう。

PHPerKaigi 2020まだチケット販売中です (私はジェネレータの話をします)
あとLaravel JP Conference 2020も3月に開催されるから遊びに来てね (私はComposerの話をします)

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした