更新履歴

  1. : 公開

はじめに

2023 年 3 月 23 日から 25 日にかけて開催予定 (記事執筆時点) のPHPerKaigi 2023において、 昨年と同様に、弊社デジタルサーカス株式会社からトークン問題を出題予定である。

昨年のトークン問題の記事はこちら:PHPerKaigi 2022 トークン問題の解説

すでに 2023 年用の問題は作成済みであるが、その制作過程の中でいくつかボツ問ができた。 せっかくなので、PHPerKaigi 開催を待つ間に紹介しようと思う。

10 月から 2 月まで、毎月 1 記事ずつ公開していく予定 (忘れていなければ → 忘れていたので 12 月公開予定だった記事を今書いている)。

問題

注意: これはボツ問なので、得られたトークンを PHPerKaigi で入力してもポイントにはならない。

<?php
try {
  f(g() / __LINE__);
} catch (Throwable $e) {
  while ($e = $e->getPrevious()) printf('%c', $e->getLine() + 23);
  echo "\n";
}
function f(int $i) {
  if ($i < 0) f();
  try {
    match ($i) {
      0 => 0 / 0,



      15, 36 => 0 / 0,
      14 => 0 / 0,
      37 => 0 / 0,










      6 => 0 / 0,

      5 => 0 / 0,

      22 => 0 / 0,




      34, 35 => 0 / 0,








      25 => 0 / 0,
      17, 21 => 0 / 0,

      24, 32 => 0 / 0,







      33 => 0 / 0,

      16 => 0 / 0,


      18 => 0 / 0,








      7 => 0 / 0,

      2 => 0 / 0,
      1, 20 => 0 / 0,
      10, 28 => 0 / 0,
      8, 12, 26 => 0 / 0,
      4, 9, 13 => 0 / 0,





      31 => 0 / 0,

      29 => 0 / 0,

      11 => 0 / 0,



      3, 19, 23 => 0 / 0,


      27 => 0 / 0,

      30 => 0 / 0,
    };
  } finally {
    f($i - 1);
  }
}







function g() {
  return __LINE__;
}

"Catchline" と名付けた作品。実行するとトークン#base64_decode('SGVsbG8sIFdvcmxkIQ==')が得られる。

トークンは PHP の式になっていて、評価するとHello, World!という文字列になる。PHPer チャレンジのトークンには空白を含められないという制約があるが、こういった形でトークンにすれば回避できる。

解説

概要

例外が発生した行数にデータをエンコードし、それをcatchで捕まえて表示している。

例外オブジェクトの連鎖

ExceptionErrorには$previousというプロパティがあり、コンストラクタの第3引数から渡すことができる。主に 2つの用法がある:

  • エラーを処理している途中に起こった別のエラーに、元のエラー情報を含める
  • 内部エラーをラップして作られたエラーに、内部エラーの情報を含める

このうち 1つ目のケースは、finally節の中でエラーを投げると PHP 処理系が勝手に$previousを設定してくれる。

<?php

try {
  try {
    throw new Exception("Error 1");
  } finally {
    throw new Exception("Error 2");
  }
} catch (Exception $e) {
  echo $e->getMessage() . PHP_EOL;
  // => Error 2
  echo $e->getPrevious()->getMessage() . PHP_EOL;
  // => Error 1
}

この知識を元に、トークンの出力部を解析してみる。

出力部の解析

出力部をコメントや改行を追加して再掲する:

<?php
try {
  f(g() / __LINE__);
} catch (Throwable $e) {
  while ($e = $e->getPrevious()) {
    printf('%c', $e->getLine() + 23);
  }
  echo "\n";
}

出力をおこなうcatch節を見てみると、Throwable::getPrevious()を呼び出してエラーチェインを辿り、Throwable::getLine()でエラーが発生した行数を取得している。その行数に23なるマジックナンバーを足し、フォーマット指定子%cで出力している。

フォーマット指定子%cは、整数を ASCII コードと見做して印字する。トークン#base64_decode('SGVsbG8sIFdvcmxkIQ==')bであれば、ASCII コード98なので、75 行目で発生したエラー、

1, 20 => 0 / 0,

によって表現されている。エラーを起こす方法はいろいろと考えられるが、今回はゼロ除算を使った。

それでは、エラーチェインを作る箇所、関数f()を見ていく。

データ構成部の解析

f()の定義を再掲する (エラーオブジェクトの行数を利用しているので、一部分だけ抜き出すと値が変わることに注意):

function f(int $i) {
  if ($i < 0) f();
  try {
    match ($i) {
      0 => 0 / 0, // 12 行目



      15, 36 => 0 / 0,
      14 => 0 / 0,
      37 => 0 / 0,

      // (略)

      30 => 0 / 0, // 97 行目
    };
  } finally {
    f($i - 1);
  }
}

前述のように、finally節でエラーを投げると PHP 処理系が$previousを設定する。ここでは、エラーを繋げるためにf()を再帰呼び出ししている。最初にf()を呼び出している箇所を確認すると、

<?php
try {
  f(g() / __LINE__); // 3 行目
function g() {
  return __LINE__; // 111 行目
}

f()には111 / 337が渡されることがわかる。そこから 1 ずつ減らして再帰呼び出ししていき、0 より小さくなったらf()を引数なしで呼び出す。引数の数が足りないと呼び出しに失敗するので、再帰はここで止まる。

エラーチェインは、最後に発生したエラーを先頭とした単方向連結リストになっているので、順に

  1. f()の引数が足りないことによる呼び出し失敗
  2. f(0)の呼び出しで発生したゼロ除算
  3. f(1)の呼び出しで発生したゼロ除算
  4. f(37)の呼び出しで発生したゼロ除算

となっている。出力の際はcatchしたエラーのgetPrevious()から処理を始めるので、1 番目のf()によるエラーは無視され、f(0)によるエラー、f(1)によるエラー、f(2)によるエラー、と出力が進む。

f()0を渡したときは 12 行目にあるmatch0でゼロ除算が起こるので、行数が 12 となったエラーが投げられる。出力部ではこれに 23 を足した数を ASCII コードとして表示しているのだった。12 + 2335、ASCII コードでは#である。これがトークンの 1文字目にあたる。

おわりに

「行数」というのはトークン文字列をデコードする対象として優れている。

  • トークンの一部や全部が陽に現れない
  • __LINE__で容易に取得できる

しかし、こういった「変な」プログラムを何度も読んだり書いたりしていると、__LINE__を使うのはあまりにありきたりで退屈になる。では、他に行数を取得する手段はないか。こうしてThrowableを思いつき、続けてエラーオブジェクトには$previousがあることを思い出した。

今回エラーを投げるのにゼロ除算を用いたのは、それがエラーを投げる最も短いコードだと考えたからである。もし 3バイト未満でThrowableなオブジェクトを投げる手段をご存じのかたがいらっしゃれば、ぜひご教示いただきたい。……と締める予定だったのだが、0/0のところを存在しない定数にすれば、簡単に 1バイトを達成できた。ゼロ除算している箇所はちょうど 26 箇所あるので、アルファベットにでもしておけば意味ありげで良かったかもしれない。