PHP8の新機能 Named Arguments をphp-srcのテストコードから読む

公開:2020/12/09
更新:2020/12/10
23 min読了の目安(約20700字TECH技術記事

この記事は、PHP Advent Calendar 2020の9日目の記事です。

PHP8がリリースされました。PHP8はいくつかPHPを普段から用いている開発者以外でも目を見張るような新機能が追加され、プログラミング言語の潮流で受け入れられている機能が豊富に取り込まれています。

代表されるところでは、

  • Named Arguments
  • Match expression v2
  • Nullsafe operator
  • Union Types v2
  • Mixed type v2

当記事では「Named Arguments」を取り上げます。ただ新機能の使い方を取り上げることに関しては筆者自身があまり興味がそそられないので、その機能を作ったテストコードを読みます。つまり、php-src内の内部コードの言語仕様をテストする phpt スクリプトを読みます。

phpt の内容には一定のルール・文法があり、それを把握することでphp-src野中を読むことができます。当記事ではその読み方を解説します。

また、筆者自身がPHPerKaigi 2019で「自作して理解する xUnit 改訂版・最終版」で、xUnitを自作してみたりと、テスティングフレームワークを読んだり構造を分析したりすることを趣味としています。この記事を通じてphp-srcのテスティングフレームワークともいえるテストスクリプト phpt とその実行構造も説明します。

PHP8の機能「Named Arguments」

今回紹介する Named Arguments に対応するRFCはこちらです。

たとえば、指定の値で配列を埋めるarray_fillを例に取るとこのように名前付け引数を渡すことができます。

// before 8.0
$filled_array = array_fill(0, 100, 50);
// after 8.0
$filled_array = array_fill(start_index: 0, count: 100, value: 50);

before 8.0ではRFC内のコードコメントではpositional argumentsという英語でしたが引数の順番で意味が決まるものです。Named Argumentでは引数に名前をつけて渡せます。ユーザーの気持ちでは順番間違えなくて嬉しいですね。また、デフォルト値が存在するようなものはこの機能によって記述がスキップできます。htmlspecialcharsの例では以下です。

$chars = htmlspecialchars("<li><a href='#'>We are PHPer</a></li>", ENT_COMPAT, 'UTF-8', false);
$chars = htmlspecialchars(string: "<li><a href='#'>We are PHPer</a></li>", double_encode: false);

htmlspecialcharsは3つデフォルト値を持つ引数がありますが、Named Argumentを利用することで、デフォルト指定の際に関心のある引数以外を気にしなくて済むようになります。さらに、truefalseを引数に渡すようなコードの自己記述性が増すため、コードをひと目見たときの意図が関数の引数の順番を知るまでもなく直感的にわかるようになります。

この機能はcontructorの引数も例外ではありません。たとえば、つぎのような引数でデフォルト値が多いclassのconstructorでも Named Argument に寄って引数が省略可能です。

final class ManyParam {
    public function __construct(
        public string $name,
        private string $default = 'default',
        private bool $opt1 = false,
        private bool $opt2 = false,
        private bool $opt3 = false,
    ) {
    }
}

$p = new ManyParam(name: 'taro', opt3: true);
echo $p->name;

なお、constructorにpublic等アクセス修飾子を設定しているのは、Constructor property promotionというおなじくPHP8の新機能です。当該定義により明示的なプロパティ定義が不要になります。

言語仕様としてはこういったものでした。ではこちらの言語仕様を実現したコミッターのコードのテストコードを見てみます。該当するPullRequestは以下となります。

そのまえに、まずphptについて・その読み方についてご紹介します。

phptについて・読み方

PHP QA公式ページにて、phptは次のように説明されています。

A phpt test is a little script used by the php internal and quality assurance teams to test PHP's functionality.

PHPの機能をテストするためのテストスクリプトがphptスクリプトファイルです。

テストスクリプトの実行環境の詳細については、『ソースコード(php-src)からPHPをビルドする流れと仕組みを手を動かしながら理解する』の「PHP内部コードのテスト実行構造」にて解説いたしましたが、ビルドしたPHPに対してrun-tests.phpというPHPスクリプトでphptファイルを解釈してテストします(内部実装については参照先記事をご覧ください)。

phptファイルの読み方

たとえば、文字の変換・部分文字列の置換を行なうstrtrのテストスクリプトは以下です。

--TEST--
strtr() function
--FILE--
<?php
$trans = array("hello"=>"hi", "hi"=>"hello", "a"=>"A", "world"=>"planet");
var_dump(strtr("# hi all, I said hello world! #", $trans));
?>
--EXPECT--
string(32) "# hello All, I sAid hi planet! #"
  • --TEST--セクションはテストタイトルを表します。テストスクリプトの規則として一行とされています。
  • --FILE--セクションはテストのINPUTとなる.phpファイルを指します。このファイルセクションのphpコードは必ず?>で閉じられています。
  • --EXPECT--セクションはテストが通ったかどうかの比較で用いられます。var_dump()の出力結果を用いられるコードが比較的多く公式の案内でも推奨されています。

基本的にはこの3つのセクションで大体の内容を把握することができます。それ以外にもいくつか仕様として用意されているセクションもあります。都度出会ったら以下のサイトから確認すると良いでしょう。

phptファイル命名規則

phptファイルは、それぞれ phpt テストが何のためのものかをかんたんに識別するために命名規則があります。実際にはすべてのファイルがこの命名規則となっているわけではありませんが推奨されているため関数のテストなどはこの命名で見分けると便利です。

Tests for bugs
       bug<bugid>.phpt (bug17123.phpt)
Tests for a function's basic behaviour
       <functionname>_basic.phpt (dba_open_basic.phpt)
Tests for a function's error behaviour
       <functionname>_error.phpt (dba_open_error.phpt)
Tests for variations in a function's behaviour
       <functionname>_variation.phpt (dba_open_variation.phpt)
General tests for extensions
       <extname><no>.phpt (dba_003.phpt)

bug{$bugid}.phpt

bugidに対するテストファイルです。たとえば、bug54514であれば<bugs.php.net>にあるid=54514の内容となります。

--TEST--
Req #54514 (Get php binary path during script execution)
--FILE--
<?php
if(realpath(getenv('TEST_PHP_EXECUTABLE')) === realpath(PHP_BINARY)) {
    echo "done";
} else {
    var_dump(getenv('TEST_PHP_EXECUTABLE'));
    var_dump(PHP_BINARY);
}
?>
--EXPECT--
done

これは2012年にRequestとして起票された内容です。PHPのバイナリパスを取得するソリューションを提供する機能に対するテストが書かれています。

{$functionname}_basic.phpt

function(関数)に対するもっともシンプルなテストケースを指しています。

The "basic" test case for a function should just address the single most simple thing that the function is designed to do. For example, if writing a test for the sin() function a basic test would just be to check that sin() returns the correct values for some known angles - eg 30, 90, 180.

上の説明にある sin_basic.phpt はphp/ext/standard/tests/math/sin_basinc.phptというテストファイルです。

正弦を求めるsin関数のテストを記述しています。

{$functionname}_error.phpt

_basicの機能と同様に関数の_errorケースをテストしています。

The "error" tests for a function are test cases which are designed to provoke errors, warnings or notices. There can be more than one error case, if so the convention is to name the test cases mytest_error1.phpt, mytest_error2.phpt and so on.

たとえば、件数を数えるcount関数の場合は、Countableなものが渡されなかった場合のエラーケースをテストしています。

--TEST--
Generators can't be counted
--FILE--
<?php

function gen() { yield; }

$gen = gen();

try {
    count($gen);
} catch (\TypeError $e) {
    echo $e->getMessage(), PHP_EOL;
}

?>
--EXPECT--
count(): Argument #1 ($var) must be of type Countable|array, Generator given

{$functionname}_variation.phpt

こちらも関数に対するテストですが、おもに境界条件をテストするといったバリエーションを扱いたい場合に用いられます。

The "variation" tests are any tests that don't fit into "basic" or "error" tests. For example one might use a variation tests to test boundary conditions.

たとえば、さきほど例に上げたsin関数では様々なバリエーションのテストが行われています。

--TEST--
Test variations in usage of sin()
--INI--
serialize_precision = 10
--FILE--
<?php
/*
 * Function is implemented in ext/standard/math.c
*/


//Test sin with a different input values

$values = array(23,
        -23,
        2.345e1,
        -2.345e1,
        0x17,
        027,
        "23",
        "23.45",
        "2.345e1",
        "1000",
        null,
        true,
        false);

for ($i = 0; $i < count($values); $i++) {
    $res = sin($values[$i]);
    var_dump($res);
}

?>
--EXPECT--
float(-0.8462204042)
float(0.8462204042)
float(-0.9937407102)
float(0.9937407102)
float(-0.8462204042)
float(-0.8462204042)
float(-0.8462204042)
float(-0.9937407102)
float(-0.9937407102)
float(0.8268795405)
float(0)
float(0.8414709848)
float(0)

{$extname}{$no}.phpt

最後にこちらは拡張の一般的なテストをする際に用いられます。たとえば、Database Extensionのテストを例に取ると次のようなテストです。

--TEST--
DBA Insert/Replace/Fetch Test
--SKIPIF--
<?php
    require_once(__DIR__ .'/skipif.inc');
    die("info $HND handler used");
?>
--FILE--
<?php
    require_once(__DIR__ .'/test.inc');
    echo "database handler: $handler\n";
    if (($db_file=dba_open($db_file, "n", $handler))!==FALSE) {
        dba_insert("key1", "This is a test insert", $db_file);
        dba_replace("key1", "This is the replacement text", $db_file);
        $a = dba_fetch("key1", $db_file);
        dba_close($db_file);
        echo $a;
    } else {
        echo "Error creating database\n";
    }
?>
--CLEAN--
<?php
    require(__DIR__ .'/clean.inc');
?>
--EXPECTF--
database handler: %s
This is the replacement text

テストファイルの読み方・ファイル命名規則を理解しました。前述したとおりそのとおりの命名規則となっていないテストファイルもあります。Zend内のテストファイル名は筆者の個人的な観測範囲ではそこまで厳密に守られているわけではありませんでした。

今回取り上げた Named Arguments のテストスクリプトについても同様ではあります。しかし、概ねここまで紹介した区分の内容が類似したファイル名で作成されているためこの命名規則は参考になります。

特定のテストを実行したい場合

上記で紹介している公式ページでは記載されていませんが、TESTS=とわたすことでテスト対象のphptファイルを選択することができます。

make test TESTS='Zend/tests/named_params/duplicate_param.phpt'

Build complete.
Don't forget to run 'make test'.

=====================================================================
Running selected tests.
PASS Duplicate param [Zend/tests/named_params/duplicate_param.phpt]
=====================================================================
Number of tests :    1                 1
Tests skipped   :    0 (  0.0%) --------
Tests warned    :    0 (  0.0%) (  0.0%)
Tests failed    :    0 (  0.0%) (  0.0%)
Tests passed    :    1 (100.0%) (100.0%)
---------------------------------------------------------------------
Time taken      :    0 seconds
=====================================================================

Named Argumentsのテスト

Named Arugmentsの実装のための変更差分はこちらのPRでした。

影響のある別機能のテストの修正もありますが、Named Arguments自体のテストはZend/tests/named_paramsディレクトリ配下に入れられています。ZendとはPHP言語のコア部分です[1]testsディレクトリ配下にもテストがありますが言語のコアな部分についてはZend/tests配下に書かれています。

なお、PHP CoreとZend VMに関しては「Zend VMにおける例外の実装」にて詳しく説明されていますが、スライド内の端的な説明では「PHPの中間コードを実行するための仮想CPU実装」と紹介されています。

あるいは、「PHP による hello world 入門」では、「PHP の公式スクリプト実行エンジンです。」とも説明されます。

Zend Engine は PHP スクリプトをパースし、メモリが許す限りの無限のレジスタ数と CISC 風の命令セットを持つ VM のオペコード(バイトコード)*1列へ変換(コンパイル)するとともに、それをインタプリタによって逐次に実行していきます。

そして、Zend VM構造についての資料は、【PHP】Zend Engine の内部を図示した記事・資料まとめにて情報をまとめてくださっています。今回は後半で内部実装をちょっと読んできますが、その際に構造理解に非常に役立つ資料が多いです。

tree -L 1 named_params
named_params
├── __call.phpt
├── __invoke.phpt
├── assert.phpt
├── attributes.phpt
├── attributes_duplicate_named_param.phpt
├── attributes_named_flags.phpt
├── attributes_named_flags_incorrect.phpt
├── attributes_positional_after_named.phpt
├── backtrace.phpt
├── basic.phpt
├── call_user_func.phpt
├── call_user_func_array.phpt
├── cannot_pass_by_ref.phpt
├── ctor_extra_named_args.phpt
├── defaults.phpt
├── duplicate_param.phpt
├── func_get_args.phpt
├── internal.phpt
├── internal_variadics.phpt
├── missing_param.phpt
├── positional_after_named.phpt
├── references.phpt
├── reserved.phpt
├── runtime_cache_init.phpt
├── undef_var.phpt
├── unknown_named_param.phpt
├── unpack.phpt
├── unpack_and_named_1.phpt
├── unpack_and_named_2.phpt
└── variadic.phpt

RFCのURL(https://wiki.php.net/rfc/named_params)やその文章の中、GitHubのPR(https://github.com/php/php-src/pull/5357)では、named parameter という言葉で定義されていたりするため、テストファイル名もnamed_paramsとなっているようです(この命名の歴史的経緯に詳しい方いたらぜひ@hgsgtkに教えて下さい)。

basic

Named Arguemntsの基本的なテストケースがこちらです。

--TEST--
Basic test
--FILE--
<?php

function test($a, $b, $c = "c", $d = "d", $e = "e") {
    echo "a=$a, b=$b, c=$c, d=$d, e=$e\n";
}

function test3(&$a, &$b, &$c = "c", &$d = "d", &$e = "e") {
    echo "a=$a, b=$b, c=$c, d=$d, e=$e\n";
}

function &id($x) {
    return $x;
}

$a = "A"; $b = "B"; $c = "C"; $d = "D"; $e = "E";

echo "SEND_VAL:\n";
test("A", "B", "C", d: "D", e: "E");
test("A", "B", "C", e: "E", d: "D");
test(e: "E", a: "A", d: "D", b: "B", c: "C");
test("A", "B", "C", e: "E");

echo "SEND_VAL_EX:\n";
test2("A", "B", "C", d: "D", e: "E");
test2("A", "B", "C", e: "E", d: "D");
test2(e: "E", a: "A", d: "D", b: "B", c: "C");
test2("A", "B", "C", e: "E");

echo "SEND_VAR:\n";
test($a, $b, $c, d: $d, e: $e);
test($a, $b, $c, e: $e, d: $d);
test(e: $e, a: $a, d: $d, b: $b, c: $c);
test(a: $a, b: $b, c: $c, e: $e);

echo "SEND_VAR_EX:\n";
test2($a, $b, $c, d: $d, e: $e);
test2($a, $b, $c, e: $e, d: $d);
test2(e: $e, a: $a, d: $d, b: $b, c: $c);
test2(a: $a, b: $b, c: $c, e: $e);

echo "SEND_VAR_NO_REF:\n";
test3(id("A"), id("B"), id("C"), d: id("D"), e: id("E"));
test3(id("A"), id("B"), id("C"), e: id("E"), d: id("D"));
test3(e: id("E"), a: id("A"), d: id("D"), b: id("B"), c: id("C"));
test3(id("A"), id("B"), id("C"), e: id("E"));

echo "SEND_VAR_NO_REF_EX:\n";
test4(id("A"), id("B"), id("C"), d: id("D"), e: id("E"));
test4(id("A"), id("B"), id("C"), e: id("E"), d: id("D"));
test4(e: id("E"), a: id("A"), d: id("D"), b: id("B"), c: id("C"));
test4(id("A"), id("B"), id("C"), e: id("E"));

echo "SEND_REF:\n";
test3($a, $b, $c, d: $d, e: $e);
test3($a, $b, $c, e: $e, d: $d);
test3(e: $e, a: $a, d: $d, b: $b, c: $c);
test3(a: $a, b: $b, c: $c, e: $e);

function test2($a, $b, $c = "c", $d = "d", $e = "e") {
    echo "a=$a, b=$b, c=$c, d=$d, e=$e\n";
}

function test4(&$a, &$b, &$c = "c", &$d = "d", &$e = "e") {
    echo "a=$a, b=$b, c=$c, d=$d, e=$e\n";
}

?>
--EXPECT--
SEND_VAL:
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=d, e=E
SEND_VAL_EX:
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=d, e=E
SEND_VAR:
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=d, e=E
SEND_VAR_EX:
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=d, e=E
SEND_VAR_NO_REF:
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=d, e=E
SEND_VAR_NO_REF_EX:
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=d, e=E
SEND_REF:
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=D, e=E
a=A, b=B, c=C, d=d, e=E

まず、

function test($a, $b, $c = "c", $d = "d", $e = "e") {
    echo "a=$a, b=$b, c=$c, d=$d, e=$e\n";
}

という型ヒンティング無しの関数に対するINPUTは次のようなバリエーションとなっています。

$a = "A"; $b = "B"; $c = "C"; $d = "D"; $e = "E";

// (省略)
// 値を引数に利用
test("A", "B", "C", d: "D", e: "E");
test("A", "B", "C", e: "E", d: "D");
test(e: "E", a: "A", d: "D", b: "B", c: "C");
test("A", "B", "C", e: "E");

// (省略)
// 変数を引数に利用
test($a, $b, $c, d: $d, e: $e);
test($a, $b, $c, e: $e, d: $d);
test(e: $e, a: $a, d: $d, b: $b, c: $c);
test(a: $a, b: $b, c: $c, e: $e);

Attribute との組み合わせ

PHP8の新機能であるAttributesとの組み合わせもテストコードにてその振る舞いを知ることができます。

--TEST--
Named params in attributes
--FILE--
<?php

#[Attribute]
class MyAttribute {
    public function __construct(
        public $a = 'a',
        public $b = 'b',
        public $c = 'c',
    ) {}
}

#[MyAttribute('A', c: 'C')]
class Test1 {}

#[MyAttribute('A', a: 'C')]
class Test2 {}

$attr = (new ReflectionClass(Test1::class))->getAttributes()[0];
var_dump($attr->getName());
var_dump($attr->getArguments());
var_dump($attr->newInstance());

$attr = (new ReflectionClass(Test2::class))->getAttributes()[0];
try {
    var_dump($attr->newInstance());
} catch (Error $e) {
    echo $e->getMessage(), "\n";
}

?>
--EXPECT--
string(11) "MyAttribute"
array(2) {
  [0]=>
  string(1) "A"
  ["c"]=>
  string(1) "C"
}
object(MyAttribute)#1 (3) {
  ["a"]=>
  string(1) "A"
  ["b"]=>
  string(1) "b"
  ["c"]=>
  string(1) "C"
}
Named parameter $a overwrites previous argument

#[Attribute]
class MyAttribute {
    public function __construct(
        public $a = 'a',
        public $b = 'b',
        public $c = 'c',
    ) {}
}

#[MyAttribute('A', c: 'C')]
class Test1 {}

#[MyAttribute('A', a: 'C')]
class Test2 {}

といったAttributeにてNamed Argumentsで引数を渡す場合の振る舞いがテストで確認されています。

error 未定義変数

未定義変数を引数に指定した際のテストケースが undef_var.phpt ファイルに記載されています。

--TEST--
Passing undefined variabled to named arg
--FILE--
<?php

function func1($arg) { var_dump($arg); }
func1(arg: $undef);
func2(arg: $undef);
function func2($arg) { var_dump($arg); }

?>
--EXPECTF--
Warning: Undefined variable $undef in %s on line %d
NULL

Warning: Undefined variable $undef in %s on line %d
NULL

(なお、variabledはtypoだなと思いPR(https://github.com/php/php-src/pull/6499)送っておいた。)

ここでは、未定義変数を引数に指定した際に、Undefined variableとWarningになることがテストされている。そしてその場合は NULL が設定される。

公式ドキュメントをただ見ているだけはエラーケースは説明されないのでテストケースを通して「こういう使い方はエラーになる」という期待値がわかります。

duplicate 重複した場合

たとえば、test(a: 1, a: 2)といったように同じ名前で二回送った場合は?

--TEST--
Duplicate param
--FILE--
<?php

function test($a) {}

try {
    test(a: 1, a: 2);
} catch (Error $e) {
    echo $e->getMessage(), "\n";
}

try {
    test(1, a: 2);
} catch (Error $e) {
    echo $e->getMessage(), "\n";
}

?>
--EXPECT--
Named parameter $a overwrites previous argument
Named parameter $a overwrites previous argument

Named parameter $a overwrites previous argumentというエラーになるようですね。引数がひとつしかない関数に対してtest(1, a: 2)のように渡しても同じエラーと処理されています。

deep dive: Zendの実装を読む

Named parameter $a overwrites previous argumentというエラーはどのように実装されているのか、Cコードを少し読んでみましょう。

エラーメッセージからgrepすると、php-src/Zend/zend_execute.c にてそのエラー発生箇所を確認できます。具体的には2箇所で発生しうります。

php-src/Zend/zend_execute.c
	if (UNEXPECTED(arg_offset == fbc->common.num_args)) {
		/* Unknown named parameter that will be collected into a variadic. */
		if (!(ZEND_CALL_INFO(call) & ZEND_CALL_HAS_EXTRA_NAMED_PARAMS)) {
			ZEND_ADD_CALL_FLAG(call, ZEND_CALL_HAS_EXTRA_NAMED_PARAMS);
			call->extra_named_params = zend_new_array(0);
		}

		arg = zend_hash_add_empty_element(call->extra_named_params, arg_name);
		if (!arg) {
			zend_throw_error(NULL, "Named parameter $%s overwrites previous argument",
				ZSTR_VAL(arg_name));
			return NULL;
		}

	if (arg_offset >= current_num_args) {
		// (省略)
	} else {
		arg = ZEND_CALL_VAR_NUM(call, arg_offset);
		if (UNEXPECTED(!Z_ISUNDEF_P(arg))) {
			zend_throw_error(NULL, "Named parameter $%s overwrites previous argument",
				ZSTR_VAL(arg_name));
			return NULL;
		}
	}

これは、zend_handle_named_arg とういう定義内でのハンドリングです。この定義はNamed Argumentsを実装した際のPRの差分にもある通り、Named Argumentsを利用したコードを使用した際にcallされるものになります。

このエラーハンドリングの実行フェーズは、ComilationではなくExecutionなようです。

PHPコードの実行ステップについて「Quick tour of PHP from inside」という資料では次のように説明しています。

  1. Startup(メモリアロケーション)
  2. Compilation
    • 字句解析(Lexing)・構文解析(Parsing)・OPCode生成
  3. Execution
    • OPCode実行
  4. Shutdown(メモリ開放)

今回たどり着いた zend_execute() はオペコードの実行フェーズに該当します。

Named parameter $a overwrites previous argumentというエラーは、オペコードの実行フェーズに発生することが想定されており、その想定ケースは2箇所のエラーハンドリングがあるため、テストケースでも2つのパターンが記述されていたと読めます。

おわりに

今回は Named Argument を例にとってテストコードを読んでいきました。テストコードによってわかることは「なにが出来るのか」・「なにができないのか」という両方です。

また、入念にテストを重ねているところから php-src の実装コード自身にも興味が湧いてきます。たとえば、「undefined variableのハンドリングは実装コードではどこに当たるのか?」といった内容を深ぼるきっかけになります。

こんな話をさらに様々な機能に渡って25分間お話させていただく時間を PHP Conference 2020 にて頂いています。

  • PHP 8 の新機能を PHP内部コードのテスト phpt から読む
  • 2020/12/12 15:20〜
  • Track5 (PHP8 Special)
  • Regular session (25 mins)

12月12日(土)のPHP Conference 2020でお会いしましょう。

脚注
  1. PHPソースコードリーディング入門(とっかかり編)
    https://anatoo.hatenablog.com/entry/20111031/1319991834↩︎