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

クロージャは可読性を落とすか

More than 3 years have passed since last update.

特定のグループIDを持っているエンティティだけを抽出して、その名前をいくらかの文字数で切り詰めて得るという処理を考える。

はじめに手続きで考えてみよう。

<?php
function issue($entities, $groupId, $textLength = 10)
{
    $clippedNamesInGroup = array();
    foreach ($entities as $entity) {
        if ($entity->getGroupId() == $groupId) {
            $clippedNamesInGroup[] = mb_strimwidth(
                $entity->getName(), 0, $textLength, '…'
            );
        }
    }
    return $clippedNamesInGroup;
}

これでは、どこからどこまでが「グループによる抽出」で、どこからどこまでが「名前の文字数切り詰め」かという区別をつけにくくなる。いまこの2つの概念は、入れ子構造を成していて不可分なものになっている。

もし、グループを問わずに名前をすべて集めたいという処理が必要になったらどうか。もし、エンティティのグループ分けだけが求められたら。

ソフトウェアは組み合わせと再利用で構成するのが望ましい。ロジックの詳細を隠蔽し、それが何であるかという「意味」だけを意識するようにして、より高度な概念を形成するのがセオリーだ。

<?php
function issue($entities, $groupId, $textLength = 10)
{
    $entitiesInGroup = array();
    foreach ($entities as $entity) {
        if ($entity->getGroupId() == $groupId) {
            $entitiesInGroup[] = $entity;
        }
    }

    $clippedNamesInGroup = array();
    foreach ($entitiesInGroup as $entity) {
        $clippedNamesInGroup[] = mb_strimwidth(
            $entity->getName(), 0, $textLength, '…'
        );
    }

    return $clippedNamesInGroup;
}

ふるい分けのみ行うことと書式化を分離することで、手間は増えるが、2つのタスクがそこにあるということが明白になった。また、個々の部分をシンプルな名前のメソッドに抽出できる可能性が出てきた。

もしメソッド抽出リファクタリングをすれば、結果はこうなるだろう。

<?php
function issue($entities, $groupId, $textLength = 10)
{
    $entitiesInGroup =
        $this->filterOnlyInGroup($entities, $groupId);

    $clippedNamesInGroup =
        $this->clippedNamesFrom($entitiesInGroup, $textLength);

    return $clippedNamesInGroup;
}

が、まだそれはしないでおこう。メソッドを取り出すのは、その必要が起こってからにし、今は、それも可能だというコードにしておくところで留めたい。フォーカスしたいのは、オブジェクト指向設計ではなく、1メソッド内のコードの品質だから。

ところで、PHPの特別な関数を使えば、foreach ループは次のように書くことができる。まあ方針を立てただけでまだこのコードは正確ではない。

<?php
function isGroupIdG001($entity)
{
    $groupId = 'g001';
    return $entity->getGroupId() == $groupId;
}

function tenCharacterClippedNameOf($entity)
{
    $textLength = 10;
    return mb_strimwidth($entity->getName(), 0, $textLength, '…');
}

function issue($entities, $groupId, $textLength = 10)
{
    $entitiesInGroup = array_filter($entities, 'isGroupIdG001');

    $clippedNamesInGroup = array_map(
        'tenCharacterClippedNameOf', $entitiesInGroup
    );

    return $clippedNamesInGroup;
}

もとの関数の中身は、メソッドを抽出したかのように美しくなった。しかしそれ以外はまるでダメだ。それぞれのグローバル関数は特定の条件にしか適合しないようにハードコードされていて、引数に指定したグループと文字数を無視している。値に応じて都度関数を定義することができればいいのだが… (答えは「できる」なのだが、古い文法を可読と呼ぶ人々はより伝統的な方法を好むので、この古い方法をもう少し見てみよう。)

<?php
function isInSomeGroup($entity)
{
    global $groupId;
    return $entity->getGroupId() == $groupId;
}

function clippedNameOf($entity)
{
    global $textLength;
    return mb_strimwidth($entity->getName(), 0, $textLength, '…');
}

function issue($entities, $groupId, $textLength = 10)
{
    global $_groupId, $_textLength;

    $_groupId = $groupId;
    $entitiesInGroup = array_filter($entities, 'isInSomeGroup');

    $_textLength = $textLength;
    $clippedNamesInGroup = array_map(
        'clippedNameOf', $entitiesInGroup
    );

    return $clippedNamesInGroup;
}

さらにひどくなった。しかし残念ながら、グローバル関数でこのまま進めるためには、このようにグローバル変数経由のパラメータ渡しをする以外に方法がない。

オブジェクトインスタンスのメソッド名を指定したコールバックと、そのインスタンスのフィールドの組み合わせは、たしかにグローバル空間を共有する必要はないが、オブジェクトの状態が無駄に増えるという問題は依然残り続ける。

<?php
class Grouper
{
    public $groupId;

    public function isInSomeGroup($entity)
    {
        return $entity->getGroupId() == $this->groupId;
    }
}

class NameExtractor
{
    public $textLength;

    public function clippedNameOf($entity)
    {
        return mb_strimwidth($entity->getName(), 0, $this->textLength, '…');
    }
}

function issue($entities, $groupId, $textLength = 10)
{
    $grouper = new Grouper;
    $grouper->groupId = 'g001';
    $entitiesInGroup = array_filter(
        $entities, array($grouper, 'isInSomeGroup')
    );

    $extractor = new NameExtractor;
    $extractor->textLength = 10;
    $clippedNamesInGroup = array_map(
        array($extractor, 'clippedNameOf'), $entitiesInGroup
    );

    return $clippedNamesInGroup;
}

もはやこうなると、手続きを追うほうが妥当だということが誰の目にも明らかだ。適切なはずの設計が単純な書き下しのコピー・ペーストに劣るのだろうか。あるいはやはり、手続きコードを隠蔽するためのメソッド抽出が適切だったのか...

ところでそろそろ、ハードコードされたグローバル関数まで戻って、「値に応じて都度関数を定義することができる」と言ったのを思い出してほしい。動的に関数を作るとは…

PHPには create_function という、「文字列から動的にグローバル関数を作る」関数があるが、それは論外だ。文字列リテラルは言語構文として認識されない。

そこでようやく、PHP5.3のクロージャだ。もともとクロージャはコールバック関数を作るものという意味ではなく、「関数が文脈を束縛する」ことを指す言葉だ。ここでいう文脈というのは、ローカル変数や引数の値のことを指す。つまり、use で変数を束縛するごとに、その変数のために都度別の関数を作っているのと同じだというのが、クロージャという言葉に含まれた意味だ。

<?php
function issue($entities, $groupId, $textLength = 10)
{
    $isInGroup = function($entity) use ($groupId) {
        return $entity->getGroupId() == $groupId;
    };

    $clippedNameOf = function($entity) use ($textLength) {
        return mb_strimwidth(
            $entity->getName(), 0, $textLength, '…'
        );
    };

    $entitiesInGroup = array_filter($entities, $isInGroup);

    $clippedNamesInGroup = array_map($clippedNameOf, $entitiesInGroup);

    return $clippedNamesInGroup;
}

クロージャを使うことで、グローバル関数だったものを、特定の関数の中に閉じ込めることができた。グローバル変数を経由していたパラメータ渡しも、スマートに関数内に閉じ込めることができた。

ここで改めて、array_filterarray_map を使うことによって、ロジック中のすべての文が代入になっていることに注目してほしい。再代入でもなく、すべて新規の代入だ。状態の代表選手、グローバル変数とオブジェクトのフィールド書き換えがあったために霞んでいたが、それがなくなった結果よく見ると、他に「状態」と呼べるものがなかったことに気づく。最終的にこのプログラムには、あらゆる意味で「状態」が存在しなくなった。

最初に言った重要なポイントは、「それが何であるかという『意味』だけを意識する」ことだった。状態をいっさい持たないプログラムでは、時間軸が無関係になる。AはBである、CはDである、という理屈だけで完結する。記述は複雑だが、このコードは「左辺は右辺である」という「意味の連鎖だけ」でできている。

また、リファクタリングの視点で見ると、変数の抽出と変数のインライン展開が自由になったことも注目に値する。可読かどうかは別として、このように書くこともできるのだ。(functionのインラインは避けるが、だいたい想像できるだろう)

<?php
return array_map(
    $clippedNameOf,
    array_filter(
        $entities, $isInGroup
    )
);

普通はここでおしまいだ。が、もう少し進めてみよう。

さらに、array_filterarray_map をメソッドチェインできる架空の SomethingChainableCollection という集合クラスがあるとしよう。ではそうなるとどうか。

<?php
function issue($entities, $groupId, $textLength = 10)
{
    return SomethingChainableCollection::from(
        $entities
    )->filter(
        function($entity) use ($groupId) {
            return $entity->getGroupId() == $groupId;
        }
    )->map(
        function($entity) use ($textLength) {
            return mb_strimwidth(
                $entity->getName(), 0, $textLength, '…'
            );
        }
    )->toArray();
}

ローカル変数がなくなり、return文だけになった。そしてこの式はなんと、ワンライナーであるにもかかわらず十分に可読だ。

この SomethingChainableCollection のようなものは未来の技術ではない。PHPコミュニティにごく一般的に存在する。

この変換結果の驚くべきところは、関数の中身が return 文の単文だということだ。文がひとつしかなければ、順序の書き換えも、文と文の間に挿入することもできない。後のメンテナンスで無神経な行挿入を許す場所がどこにもないのだ。メンテナにとって、厳しいようだが実は曖昧さのない完全なコードだと言える。後で何か処理を追加したい思ったとき、文の順序で迷う必要がなく、誰が考えても間違いなくそう修正するという種類の変更しか書けない。

ちなみに、かなり丁寧にインデントしたこのコード、最大インデントレベルは、いちばん最初のあの素朴な foreach と同じになっている。

また SomethingChainableCollection のようなライブラリがあるなしにかかわらず、クロージャの中は、スコープの閉じた空間になっている。もしカスタマイズの必要があって、後でなにか複雑な書式化が mb_strimwidth の前後に必要になったとしても、その変更は map(function($e){ ここの部分 }) の中に 必ず 閉じる。そこでローカル変数が必要になった場合、そのスコープはクロージャ内だ。一時変数のスコープが関数/メソッドよりさらにコンパクトになる。

メソッドの他の部分を読んで何か競合がないか考慮するといったタスクはいっさい必要ない。束縛する変数を明示的に書くPHPのクロージャならではの心理的な安心感が効いている。下手をするとローカル変数が漏れる言語とは違う、PHPのある種の良さではないかと思う。

やや擬似的に書くと、mb_strimwidth の前後をカスタマイズしている保守プログラマーにとって、クロージャを使った実装コードはこのように見える。

<?php
function issue($entities, $groupId, $textLength = 10)
{
    // return SomethingChainableCollection::from(
    //     $entities
    // )->filter(
    // 
    // )->map(
        function($entity) use ($textLength) {
            $name = $entity->getName();
            // ここで何かする
            return mb_strimwidth(
                $name, 0, $textLength, '…'
            );
        }
    // )->toArray();
}

彼/彼女はこの狭い function ブロック外の変数はおろか、配列やループについてすら意識する必要がない。操作は要素に対して行うレベルに落とし込まれている。しかも元のコードの読解を、別のクラス/メソッドにたらい回しされることなく、同じ記述箇所でスクロールせずに達成できる。

PHPのクロージャの記述性については、必ずしも高いとは言えない。が、記述のコストを払うことで、可読性については間違いなく向上する。なぜなら、クロージャを使うことで、読むべき状態変数が明らかに少なくなり、読む必要のないスコープ外の部分が明確になるからだ。コードを読むというのは、ステップを表面的に追いかけることではなく、関連する部分の意味をすべて読解/解析する仕事であるはずだ。意味のまとまりの結合が適切に切れてくれるということが、安易に平易な字面を使うことに勝る、本当の読みやすさではないだろうか。

Why do not you register as a user and use Qiita more conveniently?
  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
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