Perl Hackers Hub

第71回ISUCONの実装から最近のPerlを学ぶ ―わかりやすく変更しやすいコードを実現する考え方と方法(1)

本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回のハッカーは小林謙太さんで、テーマは「ISUCONの実装から最近のPerlを学ぶ」です。本稿のサンプルコードは、本誌サポートサイトから入手できます

わかりやすく変更しやすいPerlコードを学ぶ

最初に、ISUCONについて紹介します。公式サイトによると、⁠ISUCONとは、お題となるWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトル」とあります。2021年のISUCONは11回目の開催だったので、ISUCON11と呼びます。ISUCONのお題であるWebサービスは、参加者が得意なプログラミング言語を使って参加できるように複数の言語による実装を提供していますが、オリジナル実装以外は移植が必要です。筆者はこれまで学ばせてもらった恩返しで、ISUCON11の参考実装のGoからPerlへの移植に応募しました。

ISUCONは処理速度を競うので、公平性の観点で、移植されるコードはオリジナルと乖離かいりしてはいけません。その前提で、限られた競技時間を最大限に活用し、参加される方がつまらない不具合で悩まされない移植を目指しました。オリジナルと乖離してはいけないので、できる工夫は限定的でしたが、幸い読みやすいといった声をいただけてホッとしています。

ISUCONに限らず、ユーザーに価値を届け続けるために、コードのわかりやすさや変更しやすさは重要です。コードがわかりにくいと、目的とは関係のないところで時間を無駄にしたり、多人数で開発をしたときに理解がそろわず、協働しにくくなります。変更しにくいと開発速度が遅くなるだけでなく、変更を入れる恐怖などメンタルの疲弊も起こします。

本稿では、オリジナルのロジックと乖離しない範囲で、Webサービスをわかりやすく変更しやすくする方法を紹介します。具体的には、まずPerlの新機能を4つ紹介し、次にJSONエンコードについて解説します。

それらの解説に入る前に、本節では「わかりやすさ」「変更のしやすさ」について掘り下げます。

わかりやすさとは意味を理解するまでの認知の円滑さ

まず「わかりやすさ」について説明します。

本稿では認知心理学の言葉を借りて、⁠意味を理解するまでの認知の円滑さ」をわかりやすさの定義とします。認知とは、情報を知覚し、一時的に記憶された情報を知識や経験と紐付けて、意味を理解するプロセスを指します。たとえば、コードに色付けすると目で知覚しやすくなるため、わかりやすいと言えます。長大なクラスは記憶しづらく認知の負荷が高いため、わかりづらいと言えます。知覚、記憶などの人間の特性を理解することは、わかりやすいコードを書くためのヒントになると筆者は考えています。

以降では、認知の3ステップ(情報の知覚、記憶、知識や経験との紐付け)について順に説明します。

意識せずに注目を集めるとわかりやすい

まず情報の知覚に関して、コードは目で読む人がほとんどなので、視覚の特性や効果的な視覚化を行うための方法を紹介します。

視覚には「ボトムアップ処理」「トップダウン処理」の2種類があります。ボトムアップ処理は、目を開けているだけで入ってくる大量の情報からパターンを認識して、意味を当てはめていきます。たとえば、赤くて丸いと見えれば、りんごだと推測できます。ほかには、プログラムのイディオムは、パッと見て意味を理解しやすくします。それに対してトップダウン処理は、自ら意識を向けて探す処理のことです。たとえば、りんごを注意して見ると、赤、薄い赤、黄色など複数の色が混ざり、形も丸でないことがわかるはずです。ボトムアップ処理は高速に処理することに向いていますが、情報の解像度は粗いです。トップダウン処理は情報の解像度は上がりますが、処理の負担は重いです。

視覚のこの2つの処理の良いところをとって「意識せず、注目を集める」ことができれば、より効果的な視覚化と言えます。この考えは、コードだけでなく、UIUser Interface設計やデータをグラフにするときにも共通して使える考え方です。

意識せずに注目を集める方法の一つは、周りと比較して、色、形、位置などを変えることです。たとえば、プログラムの定数をすべて大文字で書く慣習は、ほかの小文字変数と意味が違うことを自然に伝えます。ほかには、姿や形のとらえ方の傾向といった視覚認知の法則を説明しているゲシュタルトの法則もヒントになります。たとえば、ゲシュタルトの法則の近接は、⁠近くにあるものは、意味が近い」と感じる法則です。この法則を利用して、意味が近いならば近くに集め、逆ならば近くに集めないコードを書けば、読み手は無意識に意味の関連をくみとってくれます。

記憶容量を節約するとわかりやすい

次に、情報の記憶について説明します。

突然ですが、おつかいを頼まれ、家を出た瞬間には内容を忘れている経験はありませんか。こういった数秒から数十秒程度しか残らない記憶を短期記憶と言い、この短期記憶の容量は数個しか覚えられないと言われます[1]⁠。

また、記憶の単位は、桁や文字数ではなく、チャンクでとらえます。たとえば、16642561024といった11桁の数字は覚えにくいですが、16-64-256-1024と区切り、16を4倍ずつしている数だとわかれば、使うチャンクは数字の16と4倍の2つだけで済み、記憶しやすくなります。

これらにより、意味の区切りのわからない冗長なコードは、記憶の容量を無駄に使います。また、複数箇所読まなければ意味が定まらないコードよりも、1ヵ所だけ読めば意味が定まるコードのほうが、記憶容量を節約でき、わかりやすくなります。簡潔なコードが好まれる理由の一つでしょう。

意図や行動を連想できるとわかりやすい

最後は、知識や経験と情報の紐付けについてです。

「さくら」と聞けば、⁠バラの仲間」⁠春」⁠入学式」⁠日本」⁠インターネットの会社」など複数の事柄が思い浮かびます。複数の事柄がつながってツリーやグラフなどの構造を持って、知識は保存されています。私たちは情報から、それが何であるかだけでなく、カテゴリ、出来事、行動なども連想できます。

この特性によって、⁠空が暗い」から「雨が降りそう」⁠傘が必要」など次の行動を連想できます。連想がしづらい例としては、price *= 1.10のようにマジックナンバーを使ったコードが挙げられます。価格を1割増加していることは推測できますが、何の1割なのか、この1割はどういったときに変更すべきなのかを連想しづらいです。知識や経験が豊富な人だったとしても、price *= 1.10だけでは情報不足です。意図や行動を連想できるコードのほうが認知負荷は少なくなります。

また、本稿では、目的達成の障害となる罠をいくつか紹介します。読み手が想像もつかない罠は苦痛です。バッドノウハウで罠を避けるだけでなく、そもそも罠をなくすことができれば、読み手に必要とする知識は少なくなり、認知負荷は下げられます。

変更しやすくするために、もとに戻しやすくする

コードの「変更のしやすさ」についても説明します。ここでの「変更」とは、ユーザーへの価値提供やシステムのパフォーマンス改善など、Webサービスの価値を増減させる変更を想定しています。

少し突飛な質問ですが、もし太宰治の『走れメロス』に変更を加え、改善してくださいと言われたら、どうでしょうか。内容がわかりやすくても、筆者には変更の想像はつきません。この例から、わかりやすくても、変更しにくいことがあると言えます。そして、変更に備えることを設計だと筆者は考えます。

ISUCONの参考実装では、コードの目的を達しているかを、早く気付ける設計を意識しました。正しい状態がわかれば、間違ってももとに戻せばよく、気軽に変更を加えやすくなります。具体的には、JSONエンコードで期待する型を明示しました。詳細は後述します。

Perlの4つの新機能

ISUCON11の参考実装ではPerl 5.34を利用し、try/catchisapostderefsignaturesという4つのPerlの新機能を利用しています。これらは、コードを馴染みのある表現にしつつ、罠を回避する機能のため、認知負荷を下げ、コードをわかりやすくします。

try/catch ─⁠─罠に悩まされる心配をなくす

ISUCON11でPerl 5.34を利用したのは、このバージョンで導入されたtry/catchを使うためと言っても過言ではありません。

従来のPerlの例外処理では、次のコードのようにevalと特殊変数の$@を使っていました。

eval { die "error!!" };
if ($@) {
  # catch error
}

ただ、実際には、eval$@を直接使うことはお勧めしません。$@がグローバル変数のため、evalが意図せずクリアされる問題があるためです。local $@で回避はできますが、抜けが発生しやすいです。

eval { die "error!!" };

# このevalで$@がリセットされる
eval { };
if ($@) {
  # no error...
}

この問題を回避するデファクトスタンダードの方法は、Try::Tinyを使うことです。ただ、ここにも罠があります。たとえば、try/catchの中でのreturnです。次のコードでは、catchreturnしても、sample_try関数の戻り値はSuccessになります。この挙動が直感と反する人はいるでしょう。なお、このコードでは、Try::Tinyのバージョン0.30を用いました。

use Try::Tiny;

sub sample_try {
  try { die }
  catch {
    # このreturnでsample_tryが終わってほしい
    # しかし、終わらない
    return "Fail"
  };
  return "Success"
}
sample_try(); # => Success

Perl 5.34からはtry/catchがビルトインされ、こういった罠を回避できます。次のコードのように馴染みの文法のまま、罠を気にする必要がなくなります。

use experimental qw(try);

sub sample_try {
  try { die }
  catch ($e) {
    return "Fail"
  } # try/catch構文になったので、末尾のセミコロンが不要
  return "Success"
}
sample_try(); # => Fail

補足として、このコードのexperimentalプラグマは、use feature qw(try)try/catchを有効化しつつ、nowarnings 'experimental::try'をして、警告を抑制してくれます。意図が簡潔に伝わるので有用です。このコードでは、experimentalのバージョン0.025を用いました。

isa ─⁠─継承をスッキリと調べる

オブジェクトの継承関係を調べるisaオペレータは、Perl 5.32から利用できます。

従来は、blessed($o) && $o->isa('Some::Class')と書いていました。継承関係を調べるisaメソッドは、$oがクラス名を指す文字列であっても呼び出せるため、Scalar::Util::blessedでオブジェクトかどうかを確認する必要がありました。

isaオペレータを使うと、そういった確認は不要になり、$o isa Some::Classと書くだけでオブジェクトの継承関係を調べられます。目的以外のコードが省け、簡潔です。

postderef ─⁠─スライスを簡潔にする

postderefは、Perl 5.24から正式な機能になったデリファレンスの文法です。たとえば、スライスはmy ($foo,$bar) = $hash->@{qw/foo bar/}と書けます。これは、ハッシュリファレンス$hashから配列@としてfoobarのキーの値を取り出すコードで、⁠リファレンスから何を取り出すか」を左から右に読み下せます。

my $arr  = [qw/a b c d e/];
my $hash = { foo => 1, bar => 2, baz => 3 };

# 従来の書き方
my @all         = @$arr;
my ($b, $c, $d) = @$arr[1..3];
my %copy        = %$hash;
my %part        = %$hash{qw/foo/}; # ( foo => 1 )
my ($bar, $baz) = @$hash{qw/bar baz/};

# postderefを利用
my @all         = $arr->@*;
my ($b, $c, $d) = $arr->@[1..3];
my %copy        = $hash->%*;
my %part        = $hash->%{qw/foo/}; # ( foo => 1 )
my ($bar, $baz) = $hash->@{qw/bar baz/};

筆者はスライスがpostderefの要だと考えます。ISUCON11では、MySQLのコネクション情報をスライスするコードを、MYSQL_CONFIG->@{qw/host user/}と書きました。

これは、従来のデリファレンスだと@{MYSQL_CONFIG()}{qw/host user/}と書きます。@{MYSQL_CONFIG()}の部分を括弧を付けずに@{MYSQL_CONFIG}と書くと、エラーになります。括弧が必要な理由は、MYSQL_CONFIG@MYSQL_CONFIGと扱われないようMYSQL_CONFIG関数を呼び出すためです。constantプラグマが作る定数が関数であることを知っていなければ、この挙動の理解は難しいでしょう。この書き方は、コードの目的以上の知識を求めます。

それと比べ、postderefを使ったスライスは、設定からhostuserを取り出す意図が簡潔にわかります。

use constant MYSQL_CONFIG => {
  host => '127.0.0.1',
  user => 'isu',
};

# postderefを利用
my ($host, $user) = MYSQL_CONFIG->@{qw/host user/};
# 従来だと
# @{MYSQL_CONFIG()}{qw/host user/} と書く必要がある
# @{MYSQL_CONFIG}{qw/host user/} と書くとエラーになる

signatures ─⁠─記号ばかりの引数をやめる

従来のPerlの関数の引数は、特殊配列変数@_に保持され、sub add { my ($a, $b) = @_; $a + $b }のように@_を分解して引数を扱います。配列@_の最初の要素を取得するには、my $a = $_[0]my $a = shiftとも書けます。これを理解するには、配列@_$i番目の要素を$_[$i]と書くことや、shift関数の暗黙の引数に@_が入るといった知識が必要です。これは初学者には優しくなく、筆者は記号ばかりと感じます。

Perl 5.20から利用できるsignaturesで、関数の引数はsub add($a, $b) { $a + $b }と簡潔に書けます。従来の方法とsignaturesを使った方法を表1で比較しました。signaturesのほうが簡潔に見えると思います。

表1 @_とsignaturesの比較
説明@_を利用signaturesを利用
2つの引数sub f { my ($a, $b) = @_; }sub f($a,$b) { }
デフォルト値sub f { my ($opt) = @_; $opt //= {}; }sub f($opt={}) { }
メソッドsub m { my ($self, $key) = @_; }sub m($self,$key) { }

蛇足ですが、上述のsub add($a, $b) { $a + $b }は数値の足し算を期待していても、数値以外の値が渡せます。次のコードのように数値以外はエラーとすれば、数値以外の値を考える必要がなくなり、コードを読む負荷が下がります。

sub add($a, $b) {
    Int->check($a) or Carp::croak(Int->get_message($a));
    Int->check($b) or Carp::croak(Int->get_message($b));
    return $a + $b;
}

こういった値に制限を加える話は、本連載第40回Perl開発への動的な型制約の導入と第52回Perlで堅牢な開発で解説されています。最近だとPerl本体で型に関する議論が進んでいるので、今後の動きにも注目です。

<続きの(2)こちら。>

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    ブロックチェーン、スマートコントラクト、NFT
    作って学ぶWeb3

おすすめ記事

記事・ニュース一覧