PHP

あったらいいな! PHPをプリプロセッサーで自分好みの言語に拡張する

2017/03/08

Christopher Pitt

17

Articles in this issue reproduced from SitePoint
Copyright © 2017, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2017, KADOKAWA ASCII Research Laboratories, Inc. Japanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

プログラミング言語に自分がほしい機能を追加できたら…。マクロとプリプロセッサー「YAY!」を使って、PHPを拡張する方法に挑戦してみました。

これからちょっとおもしろいことをしてみます。少し前にPythonのrangeの文法をPHPマクロに転用する実験をしました。そのあと、名の知れたSaraMGRFCについて言及し、LordKabeloがC#スタイルのgetterとsetterを代わりに加えることを提案しました。

外部から言語の新機能を提案し、実装することがいかに時間を要するか分かったうえで、私はエディターに向かいました。

この記事のコードはGithubで入手できます。PHP ^7.1でテスト済みで、生成したコードはPHP ^5.6|^7.0で動作します。

マクロの復習

マクロについて書いてからしばらくたったので(初めての人もいるかもしれません)、次のコードで復習します。

macro {
  →(···expression)
} >> {
  ··stringify(···expression)
}

macro {
  T_VARIABLE·A[
    ···range
  ]
} >> {
  eval(
    '$list = ' . →(T_VARIABLE·A) . ';' .
    '$lower = ' . explode('..', →(···range))[0] . ';' .
    '$upper = ' . explode('..', →(···range))[1] . ';' .
    'return array_slice($list, $lower, $upper - $lower);'
  )
}

そして次のカスタムPHPを、

$few = many[1..3];

下のように有効なPHPにします。

$few = eval(
    '$list = ' . '$many' . ';'.
    '$lower = ' . explode('..', '1..3')[0] . ';' .
    '$upper = ' . explode('..', '1..3')[1] . ';' .
    'return array_slice($list, $lower, $upper - $lower);'
);

※私が書いた記事でコードの動作を確認できます。

重要な点は、パーサーがコードの文字列をトークンに分割してマクロのパターンを構築し、そのパターンを再帰的に新たなコードに適用する方法を理解することです。

マクロライブラリー(YAY!)はドキュメントの整備が遅れていて、正確なパターンの書き方や最後に生成される有効なコードを調べるのは簡単ではありません。新しいアプリケーションごとに、この記事のような解説がないと、ほかの人は理解できません。

ベースを作る

手元にあるPHPのアプリケーションに、C#に似たgetterとsetterを加えます。取り掛かる前に、作業のベースとなるコードが必要です。この機能を追加で必要としているクラスに共通する特徴を持ったコードが良いでしょう。

クラスの定義を点検し、検出した特別なプロパティとコメントに動的なgetterとsetterメソッドを作成するコードを実装します。

最初は、特別なメソッド名のフォーマットと、マジックメソッド__get__setを定義します。

namespace App;

trait AccessorTrait
{
  /**
   * @inheritdoc
   *
   * @param string $property
   * @param mixed $value
   */
  public function __get($property)
  {
    if (method_exists($this, "__get_{$property}")) {
      return $this->{"__get_{$property}"}();
    }
  }

  /**
   * @inheritdoc
   *
   * @param string $property
   * @param mixed $value
   */
  public function __set($property, $value)
  {
    if (method_exists($this, "__set_{$property}")) {
      return $this->{"__set_{$property}"}($value);
    }
  }
}

名前が__get___set_から始まるメソッドはこれから定義するプロパティに関連付けます。次のようなコードを考えてください。

namespace App;

class Sprocket
{
    private $type {
        get {
            return $this->type;
        }

        set {
            $this->type = strtoupper($value);
        }
    };
}

これを次のようなコードに変換します。

namespace App;

class Sprocket {
    use AccessorTrait;

    private $type;

    private function __get_type() {
        return $this->type;  
    }

    private function __set_type($value) {
        $this->type = strtoupper($value);   
    }
}

マクロの定義

今回の難所は必要なマクロを定義することです。ドキュメントが整備されていない(広く利用されていない)ので、例外を知らせてくれるエラーメッセージを頼りに試行錯誤することになります。

次に示すのは数時間を費やして到達したパターンです。

macro ·unsafe {
  ·ns()·class {
    ···body
  }
} >> {
·class {
    use AccessorTrait;

    ···body
  }
}

macro ·unsafe {
  private T_VARIABLE·var {
    get {
      ···getter
    }

    set {
      ···setter
    }
  };
} >> {
  private T_VARIABLE·var;

  private function ··concat(__get_ ··unvar(T_VARIABLE·var))() {
    ···getter
  }

  private function ··concat(__set_ ··unvar(T_VARIABLE·var))($value) {
    ···setter
  }
}

この2つのマクロの動作を説明します。

  1. class MyClass { ... }をマッチさせ、先ほど作成したAccessorTraitを挿入する。これにより__get__setを実装し__get_barprint $class->barなどに接続する
  2. getterとsetterのブロックをマッチさせ、通常のプロパティ定義で置換し、いくつかの個別のメソッドの定義を続ける。そしてget { ... }set { ... }ブロックの内容をそのまま関数内に入れ込む

このコードを実行すると、エラーが出ます。マクロプロセッサーは··unvar関数を標準ではサポートしていないためです。次は$typetypeに変換するために追加したものです。

namespace Yay\DSL\Expanders;

use Yay\Token;
use Yay\TokenStream;

function unvar(TokenStream $ts) : TokenStream {
  $str = str_replace('$', '', (string) $ts);

  return
    TokenStream::fromSequence(
      new Token(
        T_CONSTANT_ENCAPSED_STRING, $str
      )
    )
  ;
}

これで、マクロパーサーに含まれている··stringify expanderを(ほぼそのまま) コピーできました。Yayの中身を分からなくても、コードの動作を理解できます。TokenStreamをstringに(この文脈で)キャストすると、tokenの参照先のstring値を得られます。この場合は··unvar(T_VARIABLE·var)で、string操作を実行します。

(string) $ts"T_VARIABLE·var"ではなく"$type"になります。

通常このマクロは、適用対象のスクリプトの内部に記載して実行します。言い換えると、次のようなスクリプトが書けます。

<?php

macro ·unsafe {
  ...
} >> {
  ...
}

macro ·unsafe {
  ...
} >> {
  ...
}

namespace App;

trait AccessorTrait
{
  ...
}

class Sprocket
{
  private $type {
    get {
      return $this->type;
    }

    set {
      $this->type = strtoupper($value);
    }
  };
}

そして次のようなコマンドを使って実行できます。

vendor/bin/yay src/Sprocket.pre >> src/Sprocket.php

最後に、このコードを次のように(ComposerのPSR-4オートロードとあわせて)使います。

require __DIR__ . "/vendor/autoload.php";

$sprocket = new App\Sprocket();
$sprocket->type = "acme sprocket";

print $sprocket->type; // Acme Sprocket

変換の自動化

これを手作業でやるのは得策ではありません。src/Sprocket.preを変更するたびにbashコマンドを実行したいなんて誰も思いません。幸いにも自動化できます!

最初にカスタムオートローダーを定義します。

spl_autoload_register(function($class) {
  $definitions = require __DIR__ . "/vendor/composer/autoload_psr4.php";

  foreach ($definitions as $prefix => $paths) {
    $prefixLength = strlen($prefix);

    if (strncmp($prefix, $class, $prefixLength) !== 0) {
      continue;
    }

    $relativeClass = substr($class, $prefixLength);

    foreach ($paths as $path) {
      $php = $path . "/" . str_replace("\\", "/", $relativeClass) . ".php";

      $pre = $path . "/" . str_replace("\\", "/", $relativeClass) . ".pre";

      $relative = ltrim(str_replace(__DIR__, "", $pre), DIRECTORY_SEPARATOR);

      $macros = __DIR__ . "/macros.pre";

      if (file_exists($pre)) {
        // ... convert and load file
      }
    }
  }
}, false, true);

このファイルをautoload.phpとして保存し、ドキュメントに書かれている通り、filesを使ってComposeのオートローダーに含めてオートロードできます。

このコードの冒頭部分はPSR-4 specificationの実装例から転用しています。ComposerのPSR-4定義ファイルを取得し、文頭ごとにロードしているクラスに一致するかチェックしています。

一致すれば、可能性があるパスをチェックして、カスタムコードを定義しているfile.preを見つけます。macros.preファイル(プロジェクトのベースディレクトリにある)の中身を取得し、macros.preと一致したファイルの中身から中間ファイルを作成します。つまり、Yayに渡すファイルからマクロを使えます。Yayがfile.pre.interimfile.phpにコンパイルしたあとに、file.pre.interimを削除します。

このプロセスのコードは次のように書けます。

if (file_exists($php)) {
  unlink($php);
}

file_put_contents(
  "{$pre}.interim",
  str_replace(
    "<?php",
    file_get_contents($macros),
    file_get_contents($pre)
  )
);

exec("vendor/bin/yay {$pre}.interim >> {$php}");

$comment = "
  # This file is generated, changes you make will be lost.
  # Make your changes in {$relative} instead.
";

file_put_contents(
  $php,
  str_replace(
    "<?php",
    "<?php\n{$comment}",
    file_get_contents($php)
  )
);

unlink("{$pre}.interim");

require_once $php;

spl_autoload_registerの末尾にある2つのboolean引数に着目してください。1つ目はこのオートローダーがローディングのエラーに対して例外を投げるかどうかです。2つ目はこのオートローダーをスタックに積むかどうかで、これにより、Composerのオートローダーより前になり、Composerがfile.phpをロードしようとする前にfile.preを変換できます!

プラグインのフレームワークを作成

この自動化はすばらしいものの、プロジェクトごとに繰り返すのは非効率です。そこで、依存項目を(言語の新機能として)composer requireする方法を考えます。

最初は次のファイルが含まれるレポジトリを作ります。

  • composer.json: 以下のファイルをオートロードする
  • functions.php:マクロパス関数を作る(ほかのライブラリーが独自のファイルを動的に追加できるようになる)
  • expanders.php··unvarのようなexpander関数を作る
  • autoload.php:Composerのオートローダーを補強し、それぞれのライブラリーのマクロファイルをコンパイル後の.preファイルにロードする

以下はcomposer.jsonです。

{
  "name": "pre/plugin",
  "require": {
    "php": "^7.0",
    "yay/yay": "dev-master"
  },
  "autoload": {
    "files": [
      "functions.php",
      "expanders.php",
      "autoload.php"
    ]
  },
  "minimum-stability": "dev",
  "prefer-stable": true
}

次はfunctions.phpです。

<?php

namespace Pre;

define("GLOBAL_KEY", "PRE_MACRO_PATHS");

/**
 * Creates the list of macros, if it is undefined.
 */
function initMacroPaths() {
  if (!isset($GLOBALS[GLOBAL_KEY])) {
    $GLOBALS[GLOBAL_KEY] = [];
  }
}

/**
 * Adds a path to the list of macro files.
 *
 * @param string $path
 */
function addMacroPath($path) {
  initMacroPaths();
  array_push($GLOBALS[GLOBAL_KEY], $path);
}

/**
 * Removes a path to the list of macro files.
 *
 * @param string $path
 */
function removeMacroPath($path) {
  initMacroPaths();

  $GLOBALS[GLOBAL_KEY] = array_filter(
    $GLOBALS[GLOBAL_KEY],
    function($next) use ($path) {
      return $next !== $path;
    }
  );
}

/**
 * Gets all macro file paths.
 *
 * @return array
 */
function getMacroPaths() {
  initMacroPaths();
  return $GLOBALS[GLOBAL_KEY];
}

マクロファイルのパスを$GLOBALSに保存することに二の足を踏むかもしれませんが、パスを保存する方法はほかにいくらでもあり、ここでは重要なポイントではありません。パターンを使ってみるために、もっとも簡単な方法を採用したまでです。

下はexpanders.phpです。

<?php

namespace Yay\DSL\Expanders;

use Yay\Token;
use Yay\TokenStream;

function unvar(TokenStream $ts) : TokenStream {
  $str = str_replace('$', '', (string) $ts);

  return
    TokenStream::fromSequence(
      new Token(
        T_CONSTANT_ENCAPSED_STRING, $str
      )
    )
  ;
}

次はautoload.phpです。

<?php

namespace Pre;

if (file_exists(__DIR__ . "/../../autoload.php")) {
  define("BASE_DIR", realpath(__DIR__ . "/../../../"));
}

spl_autoload_register(function($class) {
  $definitions = require BASE_DIR . "/vendor/composer/autoload_psr4.php";

  foreach ($definitions as $prefix => $paths) {
    // ...check $prefixLength

    foreach ($paths as $path) {
      // ...create $php and $pre

      $relative = ltrim(str_replace(BASE_DIR, "", $pre), DIRECTORY_SEPARATOR);

      $macros = BASE_DIR . "/macros.pre";

      if (file_exists($pre)) {
        // ...remove existing PHP file

        foreach (getMacroPaths() as $macroPath) {
          file_put_contents(
            "{$pre}.interim",
            str_replace(
              "<?php",
              file_get_contents($macroPath),
              file_get_contents($pre)
            )
          );
        }

        // ...write and include the PHP file
      }
    }
  }
}, false, true);

以上で追加のマクロプラグインはこの関数を使って独自のコードをシステムに組み込めます。

言語の新機能を作る

プラグインのコードができたので、クラスのアクセッサーを、自動的に適用される独立した機能へ作り変えます。そのために追加のファイルがいくつか必要です。

  • composer.json:ベースプラグインレポジトリーを要求し、次のファイルをオートロードする
  • macros.pre:このプラグインのマクロコード
  • functions.php:アクセッサーマクロをベースプラグインシステムに組み入れる
  • src/AccessorsTrait.php:前出からほぼ変更なし

以下はcomposer.jsonです。

{
    "name": "pre/class-accessors",
    "require": {
        "php": "^7.0",
        "pre/plugin": "dev-master"
    },
    "autoload": {
        "files": [
            "functions.php"
        ],
        "psr-4": {
            "Pre\\": "src"
        }
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}

次はfunctions.phpです。

namespace Pre;

addMacroPath(__DIR__ . "/macros.pre");

下はmacros.preです。

macro ·unsafe {
  ·ns()·class {
      ···body
  }
} >> {
  ·class {
    use \Pre\AccessorsTrait;

    ···body
  }
}

macro ·unsafe {
  private T_VARIABLE·variable {
    get {
      ···getter
    }

    set {
      ···setter
    }
  };
} >> {
  // ...
}

macro ·unsafe {
  private T_VARIABLE·variable {
    set {
      ···setter
    }

    get {
      ···getter
    }
  };
} >> {
  // ...
}

macro ·unsafe {
  private T_VARIABLE·variable {
    set {
      ···setter
    }
  };
} >> {
  // ...
}

macro ·unsafe {
  private T_VARIABLE·variable {
    get {
      ···getter
    }
  };
} >> {
  // ...
}

このマクロファイルは先ほどより少し冗長になりました。まだ見つけられていませんが、アクセッサーが定義される環境をもっとスマートに扱う方法があるとは思います。

統合する

すべてをきれいにパッケージ化できたので、言語の新機能はシンプルに使えます。次の短いデモを見てください!

このプラグインのレポジトリーはGithubで入手できます。

最後に

どのプラグインについても言えることですが、誤った使い方もできてしまいます。マクロも例外ではありません。このコードはおもしろい実験ではあるものの、まだ本番環境には適しません。

コードの使い勝手が悪いとコメントしたくなるかもしれませんが、そこはご容赦ください。この記事の形のままコードを使うことをおすすめはしません。

そう言いつつも、これはおもしろいアイデアだと思ってもらえたことでしょう。PHPにあったら良いと思う言語機能がほかにもありますか? クラスアクセッサーレポジトリを出発点にできます。アイデアが有効か判断できるよう、プラグインレポジトリを使って自動化することもできるでしょう。

この記事を執筆して以来、必死になって基礎ライブラリーに取り組み、このコードを組み込んでデモをするWebサイトhttps://preprocess.ioを立ち上げました。まだアルファ版ですが、この記事で取り上げたすべてのコードとそれ以外にもいくつかのコードを紹介しています。マクロを試してみたい人のために、手軽なREPLも用意しました。

(原文:How to Make Modern PHP More Modern? With Preprocessing!

[翻訳:内藤夏樹/編集:Livit

Copyright © 2017, Christopher Pitt All Rights Reserved.

Christopher Pitt

Christopher Pitt

ライター兼プログラマーでSilverStripeに勤務しています。普段はアプリケーションアーキテクチャーに取り組んでいますが、ときどきコンパイラーやロボットを作ることもあります。

Loading...