グローバルナビゲーションへ

本文へ

フッターへ

お役立ち情報Blog



「Functional PHP」PHPのための関数型プリミティブライブラリを触ってみた

今回はFunctional PHPの学習用テストコードを書いてFunctional PHPを触ってみたいと思います。

1. Functional PHPとは

PHPのための関数型プリミティブの機能セットです。
2020年11月現在ではGitHubのスターを1.7k稼いでいるライブラリです。

2. 開発環境(Docker)の準備

筆者の環境

  • Windows 10 Home
  • WSL2
  • Docker Desktop for Windows

検証ではPHP7.4.11を使用します。

PHPのDockerfileの作成

まずは Dockerfileをプロジェクト直下のphpディレクトリに配置します。

php/Dockerfile

FROM php:7.4.11-cli-alpine3.12

COPY --from=composer:2.0.3 /usr/bin/composer /usr/bin/composer

RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"

WORKDIR /app
後でFunctional PHPをインストールする時に使用するので composer:2.0.3 もここでインストールします。

docker-compose.ymlの作成

 docker-compose.yml をプロジェクト直下に作成します。

docker-compose.yml

version: '3.8'

services:
  php:
    image: functional-php
    build:
      context: php
    volumes:
      - .:/app
      - .composer:/.composer

Dockerイメージのビルド

Dockerイメージをビルドします。

docker-compose build

Building php
Step 1/4 : FROM php:7.4.11-cli-alpine3.12
7.4.11-cli-alpine3.12: Pulling from library/php
188c0c94c7c5: Pull complete
45f8bf6cfdbe: Pull complete
ce5be7974012: Pull complete
a99dd6507fe5: Pull complete
0cee627b08be: Pull complete
188c0c94c7c5: Already exists
45f8bf6cfdbe: Already exists
ce5be7974012: Already exists
a99dd6507fe5: Already exists
0cee627b08be: Already exists
3254298999d0: Already exists
da334ff0aaed: Already exists
4af1c15ddf7f: Already exists
671eb66ee260: Already exists
b5c7f3b07799: Pull complete
dadd4f316251: Pull complete
5ae0c75b60c1: Pull complete
ef7aea22e3f7: Pull complete
b1d073c7016a: Pull complete
3d1b220888ad: Pull complete
Digest: sha256:42208c454c29be9303b0e4688e6d4f2dde25b968cf1128d8b2e790bd184bb33c
Status: Downloaded newer image for composer:2.0.3
 ---> be7bc09f0fb9
Step 3/4 : RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
 ---> Running in 20694db3e1b2
Removing intermediate container 20694db3e1b2
 ---> 468abf5ee70f
Step 4/4 : WORKDIR /app
 ---> Running in f99b55db4553
Removing intermediate container f99b55db4553
 ---> 43002b85da26

Successfully built 43002b85da26
Successfully tagged functional-php:latest

ビルドしたDockerイメージでPHP7.4.11の実行を確認します。

docker-compose run --rm -u $(id -u):$(id -g) php php -v

Creating functional-php_php_run ... done
PHP 7.4.11 (cli) (built: Oct 22 2020 06:47:39) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies

DockerイメージのビルドとPHP7.4.11の実行環境の動作確認ができました。

ここまでのディレクトリ構成

.
├── .composer/
│   └── .gitkeep
├── php/
│   └── Dockerfile
├── docker-compose.yml
└── .gitignore

3. Functional PHPのインストール

Functional PHPを Packagist からcomposerでインストールしていきます。

以下のコマンドでインストールします。

docker-compose run --rm -u $(id -u):$(id -g) php composer require lstrojny/functional-php

Creating functional-php_php_run ... done
Using version ^1.14 for lstrojny/functional-php
./composer.json has been created
Running composer update lstrojny/functional-php
Loading composer repositories with package information
Updating dependencies
Lock file operations: 1 install, 0 updates, 0 removals
  - Locking lstrojny/functional-php (1.14.1)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 1 install, 0 updates, 0 removals
  - Downloading lstrojny/functional-php (1.14.1)
  - Installing lstrojny/functional-php (1.14.1): Extracting archive
Generating autoload files

 docker-compose コマンドの実行オプションで -u $(id -u):$(id -g) を指定する事でrootユーザーでvendorディレクトリが作られることを回避します。

学習テスト用にPHPUnitもインストールします。

docker-compose run --rm -u $(id -u):$(id -g) php composer require --dev phpunit/phpunit
Creating functional-php_php_run ... done
Using version ^9.4 for phpunit/phpunit
./composer.json has been updated
Running composer update phpunit/phpunit
⋮
6 package suggestions were added by new dependencies, use `composer suggest` to see details.
Generating autoload files
26 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
 composer.json にautoload-devの設定を追加します。

composer.json

--- a/composer.json
+++ b/composer.json
@@ -4,5 +4,10 @@
     },
     "require-dev": {
         "phpunit/phpunit": "^9.4"
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "Test\\": "tests/"
+        }
     }
 }

phpunit.xml.distも追加しておきます。

phpunit.xml.dist

<?xml version="1.0" encoding="UTF-8"?>

<phpunit
    colors="true"
    convertErrorsToExceptions="true"
    convertNoticesToExceptions="true"
    convertWarningsToExceptions="true"
    verbose="true"
    stopOnFailure="false"
    processIsolation="false"
    backupGlobals="false"
    cacheResult="false"
>
    <testsuites>
        <testsuite name="Testing">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

ここまでのディレクトリ構成

.
├── .composer/
├── php/
│   └── Dockerfile
├── vendor/
├── composer.json
├── composer.lock
├── docker-compose.yml
├── .gitignore
└── phpunit.xml.dist

Functional PHPを触ってみる準備が完了しました。

4. map コレクションの各要素をマッピング

mapはコレクションの各要素にコールバック関数を適用して結果を返す関数です。

ドキュメントとテストコードを見ながらmapを使ってみます。

以下はコレクションの各要素にプラス1する関数を適用するテストコードです。

tests/MapTest.php

<?php
declare(strict_types=1);

namespace Test;

use ArrayIterator;
use Generator;
use PHPUnit\Framework\TestCase;
use function Functional\map;

class MapTest extends TestCase
{
    /**
     * @dataProvider コレクションの型別データ
     * @param mixed $collection
     */
    public function test_コレクションの各要素にプラス1するコールバック関数を渡すと適用した結果を返す($collection)
    {
        $fn = function (int $v) {
            return $v + 1;
        };
        $expected = range(2, 11);

        $this->assertSame($expected, map($collection, $fn));
    }

    /**
     * @return Generator
     */
    public function コレクションの型別データ(): Generator
    {
        $parameter = range(1, 10);

        yield 'Array' => [$parameter];
        yield 'ArrayIterator' => [new ArrayIterator($parameter)];
    }
}

実行結果

docker-compose run --rm -u $(id -u):$(id -g) php vendor/bin/phpunit --testdox

Creating functional-php_php_run ... done
PHPUnit 9.4.2 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.11
Configuration: /app/phpunit.xml.dist

Map (Test\Map)
 ✔ コレクションの各要素にプラス 1するコールバック関数を渡すと適用した結果を返す with Array  3 ms
 ✔ コレクションの各要素にプラス 1するコールバック関数を渡すと適用した結果を返す with ArrayIterator  1 ms

Time: 00:00.004, Memory: 6.00 MB

OK (2 tests, 2 assertions)

1から10の配列やイテレータの各要素にコールバック関数が適用された結果が取得できました。

5. select (filter) コールバックを使用してリスト内の各要素をフィルタリング

selectはコレクションの各要素にコールバック関数を適用してフィルタリングした結果を返す関数です。 JavaScriptの配列型オブジェクトAPIのfilterと同じ用途ですね。

Functional PHPにもエイリアスとしてfilter関数が定義されています。

こちらもドキュメントとテストコードを見ながらfilterを使ってみます。

以下はコレクションの各要素で偶数の数値を抽出するテストコードです。

tests/SelectTest.php

<?php
declare(strict_types=1);

namespace Test;

use ArrayIterator;
use Generator;
use PHPUnit\Framework\TestCase;
use function Functional\select;

class SelectTest extends TestCase
{
    /**
     * @dataProvider コレクションの型別データ
     * @param mixed $collection
     */
    public function test_コレクションの各要素で偶数を抽出した結果を返す($collection)
    {
        $fn = function (int $v) {
            return $v % 2 === 0;
        };
        $expected = [2, 4, 6, 8, 10];

        $this->assertSame($expected, select($collection, $fn));
    }

    /**
     * @return Generator
     */
    public function コレクションの型別データ(): Generator
    {
        $parameter = range(1, 10);

        yield 'Array' => [$parameter];
        yield 'ArrayIterator' => [new ArrayIterator($parameter)];
    }
}

実行結果

docker-compose run --rm -u $(id -u):$(id -g) php vendor/bin/phpunit tests/SelectTest.php --testdox

Creating functional-php_php_run ... done
stty: standard input
PHPUnit 9.4.2 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.11
Configuration: /app/phpunit.xml.dist

Select (Test\Select)
 ✘ コレクションの各要素で偶数を抽出した結果を返す with Array  3 ms
   ┐
   ├ Failed asserting that two arrays are identical.
   ┊ ---·Expected
   ┊ +++·Actual
   ┊ @@ @@
   ┊  Array &0 (
   ┊ -····0·=>·2
   ┊ -····1·=>·4
   ┊ -····2·=>·6
   ┊ -····3·=>·8
   ┊ -····4·=>·10
   ┊ +····1·=>·2
   ┊ +····3·=>·4
   ┊ +····5·=>·6
   ┊ +····7·=>·8
   ┊ +····9·=>·10
   ┊  )
   │
   ╵ /app/tests/SelectTest.php:24
   ┴

 ✘ コレクションの各要素で偶数を抽出した結果を返す with ArrayIterator  1 ms
   ┐
   ├ Failed asserting that two arrays are identical.
   ┊ ---·Expected
   ┊ +++·Actual
   ┊ @@ @@
   ┊  Array &0 (
   ┊ -····0·=>·2
   ┊ -····1·=>·4
   ┊ -····2·=>·6
   ┊ -····3·=>·8
   ┊ -····4·=>·10
   ┊ +····1·=>·2
   ┊ +····3·=>·4
   ┊ +····5·=>·6
   ┊ +····7·=>·8
   ┊ +····9·=>·10
   ┊  )
   │
   ╵ /app/tests/SelectTest.php:24
   ┴

Time: 00:00.012, Memory: 6.00 MB


FAILURES!
Tests: 2, Assertions: 2, Failures: 2.

テストが通りませんでした。
結果を見るとコレクションのインデックスを維持した結果を返すようです。

テストコードを修正して再度テストを実行してみます。

--- a/tests/SelectTest.php
+++ b/tests/SelectTest.php
@@ -21,7 +21,8 @@ class SelectTest extends TestCase
         };
         $expected = [2, 4, 6, 8, 10];

-        $this->assertSame($expected, select($collection, $fn));
+        // array_valuesでインデックスを振りなおす
+        $this->assertSame($expected, array_values(select($collection, $fn)));
     }

     /**

再実行結果

docker-compose run --rm -u $(id -u):$(id -g) php vendor/bin/phpunit tests/SelectTest.php --testdox

Creating functional-php_php_run ... done
PHPUnit 9.4.2 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.11
Configuration: /app/phpunit.xml.dist

Select (Test\Select)
 ✔ コレクションの各要素で偶数を抽出した結果を返す with Array  3 ms
 ✔ コレクションの各要素で偶数を抽出した結果を返す with ArrayIterator  1 ms

Time: 00:00.005, Memory: 6.00 MB

OK (2 tests, 2 assertions)

テストが通るようになりました。

JavaScriptのfilter関数がお馴染みの方はselect関数のエイリアスとしてfilter関数も定義されていますので好みで使う関数を選べるようになっています。

6. 実行速度の計測

どのくらい実行速度に差が出るか比較してみます。

1から100万までの数値が入った配列の各要素を1加算する処理の実行時間を簡易的な方法ですが計測してみました。

書き方 実行時間
foreach 0.38007283210754
array_map 0.5925190448761
Functional\map 1.2633831501007

Oh… 😱

動作速度が遅く速度が求められる要件では注意が必要ですね。

計測で使用したソースコード

<?php
declare(strict_types=1);

use function Functional\map;

require_once __DIR__.'/vendor/autoload.php';

function createStopwatch(): Closure {
    $start = microtime(true);

    return function() use ($start) {
        $end = microtime(true);

        return $end - $start;
    };
}

function loop(array $range): float {
    $stopwatch = createStopwatch();

    $result = [];
    foreach ($range as $item) {
        $result[] = $item + 1;
    }

    return $stopwatch();
}

function arrayFunction(array $range): float {
    $stopwatch = createStopwatch();

    array_map(
        function(int $i): int {
            return $i + 1;
        },
        $range
    );

    return $stopwatch();
}

function functionalPHP(array $range): float {
    $stopwatch = createStopwatch();

    map($range, function(int $i):int { return $i + 1; });

    return $stopwatch();
}

function xrange() {
    return range(1, 10000000);
};

echo loop(xrange()), PHP_EOL;
echo arrayFunction(xrange()), PHP_EOL;
echo functionalPHP(xrange()), PHP_EOL;

7. まとめ

for文でコレクションのループ中に何かしら処理を行う場合には、一時変数に結果を代入する事が多く、下の例では一時変数 result が可変になります。

$result = [];
for ($i = 0; $i < 10; $i++) {
	$result[] = $i + 1;
}

好みにもよると思いますが、 筆者は上の例では簡単な処理なので脳内でどういう状態かを把握できますが、 複雑な処理になってきた場合に、一時変数が脳内のメモリを圧迫していきます。
その点、一時変数を使わない、変数を不変に保つようなmapなどの関数型な操作は便利だと思います。

ただし、計測の通り実行速度は確実にその他の方法よりも遅くなるので速度が求められる要件では注意が必要です。 その他でもテストコードを書いていて配列以外の型を渡した場合でも結果が全てプリミティブな配列に変換されてしまうのが少し気になりました。

簡易的ですがFunctional PHPのご紹介は以上です。
公式のGitHubではその他の関数型プリミティブのドキュメントがありますので、 興味のある方は覗いてみてください。

この記事を書いた人

美髭公
美髭公事業開発部 web application engineer
2013年にアーティスに入社。システムエンジニアとしてアーティスCMSを使用したWebサイトや受託システムの構築・保守に携わる。環境構築が好き。
この記事のカテゴリ

FOLLOW US

最新の情報をお届けします