ヨーダ記法の可能性に関する考察【ヨーダセマンティクスとプログラミング哲学】

ヨーダ記法の優れた特性や利点・メリットについて紹介していく。本記事の内容は極めてマニアックなものとなっている。まともで健全なプログラマの多くは、このような細かい作法を気にしてコードを書く必要はないと思われる。もっとも、異なる文化の異なるプログラマの思想や哲学に興味のある読者や、他人の妙なこだわりに関心のある読者にとっては、大変面白く興味深い内容になるかもしれない。

目次

意味の明確化を実現する

ヨーダ記法によって、代入処理と演算処理をそれぞれ異なるセマンティクスで表現することが可能となる。

let state = -1
if -1 == state {}

上記のコードはそれぞれstate = -1(左辺値,オペレータ,右辺値)の並びと、-1 == state(右辺値,オペレータ,左辺値)の並びという全く異なる規則で表現されている。「式の右辺に定数が書かれている場合は代入処理」で「式の左辺に定数が書かれている場合は演算処理」という単純明快な規則で処理の意味論(セマンティクス)を表現しているのである。

この規則によって、読み手は、定数が左辺に書かれている式を見た際に、それが何かしらの演算処理や比較処理であることを瞬時に理解できるようになる。逆に定数が右辺に記述された式の場合は、代入式やデータ構造の構築式などによる何かしらの定義を行っていることが理解できる。

dic = {key => 99}   // 定義
if 99 <= dic.key {} // 演算

ヨーダセマンティクス

このように、ヨーダ記法の適切な活用はコードの役割を明確化したり、コードに付加的な意味を与えることにも繋がる。それらは明確な意味として機能することもあれば、暗黙的な意味として機能することもある。これらを仮に「ヨーダセマンティクス」と呼ぶことにしよう。このヨーダセマンティクスに関する具体的な概念や考え方については、次項以降に順次触れていく。

ヨーダ記法はコードの表現技法としての可能性を多く秘めている。場合によっては、ヨーダ記法は良い意味でのコードスメル(code smell - コードの匂い)もといコードの香り(code scent)を生み出してくれるかもしれない。

処理の目的が明確になる

ヨーダ記法における左辺側の式は、簡潔な記述になることが多い。

if ("" == getPost("form").getString("名前"));
if (0 < getPost("form").getInt("年齢"));
if (SELECTED != getPost("form").getInt("受け取る"));

この"", 0, SELECTEDがその左辺側の式にあたるものだ。短く簡潔で実に明快な記述である。そしてそのすぐ隣に記述されている演算子(==, <, !=)は、これからやろうとしている処理の概要を如実に表している。

この"" == という記述を見るだけで、読み手はこれから「空文字のチェック」をしようとしている、ということを瞬時に理解することができる。同じように、0 < は「範囲チェック」、SELECTED != は「選択状態の判定」を表していることが直感的に理解できる。

ヨーダ記法は結論ファースト

このように、「処理の目的を左辺側で表せる」というのが、このヨーダ記法最大の特徴となっている。また、ヨーダ記法で書かれた式は、処理の目的を理解するまでの時間が遥かに短くなるという特性も併せ持つ。処理の目的を早い段階で把握できれば、その後の右辺の式はその前提をもって読み解くことができるため、効率的なコードリーディングにも繋がる。

ヨーダ記法を用いない場合

しかし、一般的な記法ではそうはいかない。

if (getPost("form").getString("title") != "");

上記の記法で、読み手が初めに目にするのは、左辺側に書かれたgetPost("form").getString("title")というやたらと複雑な式である。読み手はこの左辺側の式を読み終えた後に != ""を読み解くことになる。そしてここで始めて、処理の全体像を把握することとなるのだ。冗長的で非効率な文である。

左辺側で使用されている関数の名称から、処理の目的や意図をある程度推測することも可能だが、その方法は認知負荷が大きく、コストが高い。では、このようなヨーダ記法を用いない一般的な記法で、効率的にコードを読み解くにはどうすればいいか。

「先読み」をすればよい。条件式を左から右に向けて読み進めていくのではなく、処理の末尾から先頭にかけて読み進めていくのである。多くのプログラマはこれを無意識のうちに行っているはずである。ただ、このアクロバティックなコードリーディングの技法は、余計なバックトラックや眼球運動を引き起こすことにも繋がるため、あまり褒められたやり方ではないかもしれない。もっともヨーダ記法ならこのような苦労は不要のものとなる。

定数値の列挙を実現できる

他にも、ヨーダ記法の活用によって様々な利点が得られる。

例えば、ヨーダ記法を活用することで、コードの左辺側に肝心な定数が表示されるようになる。

if (UTF8 == request->encoding) {
   /* do something */
} else if (EUC == request->encoding) {
   /* do something */
} else if (SJIS == request->encoding) {
   /* do something */
} else if (ASCII == request->encoding) {
   /* do something */
}

このような定数は、左辺側に並ぶことによってより読み手の目に留まりやすくなる。定数は分岐のバリエーションを表す重要な要素となるため、このような記述よって定数を引き立てることは、非常に有益な発想であると考えられる。

またこれらの記述は、switch文利用時の定数列挙にも良く似たパターンとなっており、ヨーダ記法の活用はこれらと同様に、規則的で読みやすいコードの実現にも繋がることがわかる。

// switch文の利用によってもたらされる構造的で読みやすい記述
switch (request->encoding) {
   case UTF8:
      /* do something */
   case EUC:
      /* do something */
   case SJIS:
      /* do something */
   case ASCII:
      /* do something */
}

定数値の表示位置が安定する

ヨーダ記法における定数値の表示位置は、右辺側の記述量に依存しないため、定数は常に左辺の決まった位置に置かれるという特性がある。

// ヨーダ記法
if ("First" != list[0]);
if ("Last" != list[list.size() - 1]);

// 一般的な記法
if (list[0] != "First");
if (list[list.size() - 1] != "Last");

一般的な記法の例では、文字列定数の表示位置が左辺側の式の長さに依存するため安定しない。

次のようなコード整列を行えば若干安定するが面倒である。また定数の表示位置がコード修正時やリファクタリング時に移動してしまうという問題もある。

// 空白で定数を揃える
if (list[0]               != "First");
if (list[list.size() - 1] != "Last");

// コード修正時やリファクタリング時にずれる
if (list.front()               != "First");
if (list.back() != "Last");

他にも、一般的な記法に対するコード変更には、コード全体の印象を大きく変えてしまうという問題がある。

if (list[list.size() - 1] != "Last") return;
↓
if (list.back() != "Last") return;

そのような印象の変化は、コードの保守性にも少なからず影響を与える可能性がある。場合によってはコードの構造を再認識するための余計な認知コストが求められてしまうかもしれない。

しかしヨーダ記法による記述であれば、右辺側のコードを変更しても左辺側の定数の移動が発生しないため、コード全体の雰囲気が大きく崩れることはない。

if ("Last" != list[list.size() - 1]) return;
↓
if ("Last" != list.back()) return;

右辺の式を何度編集しても、文字列定数"Last"は常に定位置をキープすることになる。またこの性質により「if文字列!=」はまとまりを持った普遍的な式として認知でき、またこれはある種の定型文としての側面を強く持つこととなる。

演算子の優先順位を示せる

ヨーダ記法を的確に使い分けることで、演算子の優先順位を単純明快に表すことができる。

return mask == 0xF0 << shift;   // 一般的な「左辺値 == 右辺値」式
return 0xF0 << shift == mask;   // ヨーダ記法

return mask == (0xF0 << shift); // こう書く必要がなくなる

次のような左辺比較でも有効である。

if (pos + n > str.length) return false;

トリッキーなコードにおいても有効に働く。

if (modifierFlags == (Ctrl | Alt))
↓
if (Ctrl + Alt == modifierFlags)

これらの記述は条件式の評価基準が緩い言語(0以外の演算結果を真と見なすC言語やスクリプト言語など)で大変有効な記述となる。もっとも本来その手の言語では、丸括弧を的確に用いて演算子の優先順位を明示するべきではあるが。

似たような効果は他にもある。例えば次のコードは、

r = typeof "s" == "undefined";

以下のような優先順位で評価される。

r = (typeof "s") == "undefined"; // r = false

ただ、人によっては次のように解釈してしまうかもしれない。

r = typeof ("s" == "undefined"); // r = "boolean"

ヨーダ記法ならこのような余計な解釈は起こらない。

r = "undefined" == typeof "s";

typeofはキーワードであるため"undefined" == typeof同士が評価されることはない。故に"undefined"typeof "s"の結果と比較されることが容易に想像できる。

コードにメリハリが付く

ヨーダ記法はコードにメリハリを効かせるための手段としても活躍する。

fn(foobar, hogepiyo < 9); // ①
fn(foobar, 9 > hogepiyo); // ②

①のコードは誤釈しやすい。foobar, hogepiyoという記述だけを見ると、「2つの変数を実引数に渡しているだけの処理」のように見えてしまうが、実際には第二引数の実引数にはhogepiyo < 9という条件式の結果が渡されている。hogepiyoという変数をfn関数の第二引数に渡しているわけではないのである。

このような勘違いは、複雑なコードを読んでいる際にしばしば起こりうる。コードを左から右に向けて読み進めていく以上は、避けては通れない問題である。

しかし②のヨーダ記法を用いたコードであれば、9 >という記述を見た段階で、fn関数の第二引数は条件式の結果を受け取るものだということが瞬時に理解できる。hogepiyo <よりも遥かに短く、言わば慣用句のような、簡潔なフレーズとなっている点も重要だ。

同様に、ヨーダ記法を的確に使い分けることで、リテラル値の列挙を回避することもできる。

// Before
fwrite(pstr + 1, 1, size, stdout);
// After
fwrite(1 + pstr, 1, size, stdout);

この場合の1, 1という記述は意味があるようでない不思議な記述である。意味や関係があるように見えて、実際には何の意味もない記述は、読み手にとっての判読性を損なう恐れがある。しかしヨーダ記法を的確に用いることで、この曖昧な両式の関係性を明確に区別する事ができる。

コードに規則性が生まれる

ヨーダ記法を用いるとコードに規則性が生まれやすくなる。

// ①
if (application_name == null) return -1;
if (exp == null) return -1;
if (version > 99) score += 1;
if (val > -1) score += 1;

bool foundA() { return this.find(string) != -1; }
bool foundB() { return this.find(string, position) != -1; }

// ②
if (null == application_name) return -1;
if (null == exp) return -1;
if (99 < version) score += 1;
if (-1 < val) score += 1;

bool foundA() { return -1 != this.find(string); }
bool foundB() { return -1 != this.find(string, position); }

上記のコードは ② のほうが明らかに規則的である。「nullチェック処理が2つ」と「数値比較処理が2つ」、「検索方法のバリエーションが2つ」という規則を視覚的・直感的に瞬時に理解できる。判読性と視認性は相当高いことがうかがえる。

逆に ① のコードに規則性を持たせ判読性と視認性を向上させるためには、概ね以下のようなコード揃えが必要になる。

// 改善案1
if (application_name == null) return -1;
if (             exp == null) return -1;
if (version > 99) score += 1;
if (val     > -1) score += 1;

bool foundA() { return this.find(string)           != -1; }
bool foundB() { return this.find(string, position) != -1; }

// 改善案2
if (app == null) return -1;
if (exp == null) return -1;
if (ver > 99) score += 1;
if (val > -1) score += 1;

こうすることで、コードの規則性や法則性をより効率的に読み手に示すことができるようになったわけだが、そもそもヨーダ記法を用いた ② の方法であれば、このような面倒なコード整形は不要になる。

ヨーダ記法には、非常に少ない労力で手軽にしかも自然な形で、コードに規則性を持たせることができるというメリットがある。

ネスト式が綺麗になる

ヨーダ記法の優れた点として、連続した括弧((の回避が可能になるというものがある。

// 一般的な記法
while ((line = next()) != null);

これは完全に好みやセンスの問題かもしれないが、個人的には通常の記法に見られる((という記述は紛らわしく感じる。入れ子構造に対する余計な推測や解釈が必要になってしまうためだ。

逆にヨーダ記法で記述すれば、それが回避できる。

// ヨーダ記法
while (null != (line = next()));

逆に)))というエキゾチックな記述が生まれてしまうが、コードリーディングに慣れてくれば、このような右辺側の記述は脳内では無意識のうちに無視できるようになる。

一般的な記法の際にみられる((という記述は、左辺側に書かれてしまうため、嫌でも目に付いてしまうという問題があるが、ヨーダ記法にはこれを)))という形でコードの隅に追いやることができるという効果がある。

ただ((という記述がコードリーディング時の効率性を損なうことは限定的であり、これらは結局の所、慣れや好み、気分の問題にすぎないのかもしれない。

ちなみにこの話は、Objective-C言語において、[[NSObject alloc] init]記法よりも[NSObject.alloc init]記法のほうが読みやすいと感じる際の、あのセンスにも若干通じるものがある。
なおC言語ではif ((c = getchar()))という記法が用いられることがあるため、セマンティクスの多様性という観点においては、ヨーダ記法による区別は有益であるとも考えられる。
ちなみに近年では制御文の丸括弧の省略が可能な言語も存在するため、while (line = next()) != null {}という違和感の少ない記述も可能となっている。

ショートコーディングや最適化との愛称が良い

大したメリットではないが、ヨーダ記法を用いるとショートコーディングが捗る。

return s=="";
return e==-1;
↓
return""==s;
return-1==e;

またリテラル値を左辺に記述すると、構文解析時やコード最適化時、実行時の負担軽減に繋がる可能性が考えられる。

// `""`と`==`を読み込んだ時点で空文字チェックの処理であることが分かる
"" == f() // fの戻り値は文字列型の可能性が高いことが分かる

// `f()`と`==`を読み込んだ時点ではまだ最適化可能な処理と判断できない
f() == ""

もっとも、よほど変態的なパーサーや処理系でもない限りは左辺比較の特性がコンパイル効率や処理効率に寄与するようなこともないだろう。

左辺比較の原則

今回紹介した多くの考え方は、つまるところ「簡潔な式を左辺に記述し、複雑な式を右辺に記述する」という単純な発想に基づいている。

そのため左辺に記述する式は、なにも定数や値、関数呼び出し式などの右辺値に限定する必要はない。

int value = get("a");

// 一般的なヨーダ記法
if (get("b") == value);

// ヨーダ記法にとらわれないより良い記述
if (value == get("b"));

これは一般的なヨーダ記法の記述とは相反するものであるが、ヨーダセマンティクスの考えにおいては、簡潔な一時変数は定数と同様、重要な「結論」として捉えることができる。よって、これらの記述もヨーダ記法の範疇にあると見なすことができる。

なお一時変数を定数として宣言することで、ヨーダ記法最大の利点である「安全性」のメリットを享受することもできる。

const int value = get("a");
if (value = get("b")); // コンパイルエラー

一時変数利用時にヨーダ記法を用いるか用いないかの判断基準については、一時変数に対応する比較先の式の結果が純粋な定数かそうでない定数かどうかで判断するとよい。

たとえばリテラル値や名前付きの定数は不変な値であるため、左辺に記述することができる。

if (0 == value);
if (UTF8 == encoding);

逆に、関数呼び出しやメンバアクセスの結果については、データの状態によって可変的な値を返すことがあるため、厳密な定数式とは見なせない。よってヨーダ記法を用いる必要はない。

if (value == file.eof);
if (value == getchar());

副作用を伴わない関数呼び出しの結果については定数とみなすことができる(これはとりわけ関数型プログラミングにおいて有用なスタイルともなりうる)。

if (sel_registerName("a") == value);

動的な値を受け取る関数は動的な値を返すため、定数式として捉えることはできない。よって無理に左辺に記述する必要はない。

if (value == sel_registerName(arg));

定数を格納した一時変数は再代入を行わない限りは、定数とみなすこともできるため、左辺に記述することができる。

var empty = ""
if (empty == left)  left  = "<";
if (empty == right) right = ">";

要は、不変的な結果を左辺に記述するという単純な考え方である。

なお例外的に、純粋な定数との比較を行う際であっても、一時変数が一つのコードブロック内で複数回利用されるような場面では、ヨーダ記法による比較を避け、一時変数を主体とした比較を行うようにすると可読性の高いコードとなる。

// ヨーダ記法
if ("moveUp:" == selector) return moveUp(sender);
if ("moveDown:" == selector) return moveDown(sender);
if ("moveUpAndModifySelection:" == sender) return moveUpAndModifySelection(sender);
if ("moveDownAndModifySelection:" == selector) return moveDownAndModifySelection(sender);

// より良い記述
if (selector == "moveUp:") return moveUp(sender);
if (selector == "moveDown:") return moveDown(sender);
if (sender   == "moveUpAndModifySelection:") return moveUpAndModifySelection(sender);
if (selector == "moveDownAndModifySelection:") return moveDownAndModifySelection(sender);

同一の変数が縦方向に綺麗に整列するため、上記の例のようにselectorを誤ってsenderと記述してしまっても、その場で誤りに気づくこともできる。

ヨーダ記法で同等の可読性と安全性を実現するためには、少し手間にはなるが以下のように比較演算子を揃えるようにすると良い。

// ヨーダ記法版の改善案
if ("moveUp:"                     == selector) return moveUp(sender);
if ("moveDown:"                   == selector) return moveDown(sender);
if ("moveUpAndModifySelection:"   == selector) return moveUpAndModifySelection(sender);
if ("moveDownAndModifySelection:" == selector) return moveDownAndModifySelection(sender);

// より良い改善案(一部のプログラミング言語で利用可能)
switch (selector) {
   case "moveUp:":                     return moveUp(sender);
   case "moveDown:":                   return moveDown(sender);
   case "moveUpAndModifySelection:":   return moveUpAndModifySelection(sender);
   case "moveDownAndModifySelection:": return moveDownAndModifySelection(sender);
}

コードを揃えることの意図や具体的な効果については、以下記事の内容が大変参考になる。

参考: コード整列のススメ

関数呼び出し式への応用

ヨーダ記法は関数呼び出し時の実引数指定にも応用することができる。

equal(value, 99) // 一般的な記述
equal(99, value) // ヨーダ記法を応用した記述

この場合のequal(99, ...は、慣用句としても機能する。equal99という普遍的な要素が一箇所に集約されているためだ。逆に右辺値を左側に記述してしまうと、肝心な定数値が変数名の長さに依存する形で右側へ押しやられてしまうため、慣用句としての性質は薄まってしまう。

equal(object.method().chain(), 99)

// 処理の概要が掴みにくい
equal(to_string(object->value), "")
// 空文字チェックであることが左辺側の記述だけで瞬時に理解できる
equal("", to_string(object->value))

なお、複雑な式を前方の引数に指定してしまうと、その後の引数の関係が曖昧になってしまうという問題もある。以下の例では空文字""が、どの関数の引数に渡されているのかが理解しづらい。

print(hoe(goo(fee(object->method()), ""), 9));

fee関数の第二引数に渡されているようにも見えるし、hoe関数に渡されているようにも錯覚してしまうが、実際にはgoo関数の第二引数に渡されている。逆に、短く簡潔な式である""の方を前方に記述するようにすれば、""fee関数呼び出し式の両者がgoo関数の引数として渡されているという事実をより直感的に示すことができる。

print(hoe(goo("", fee(object->method())), 9));

このように、左定数の発想は入れ子の関係や範囲を明確にするための手段としても活用することができる。またこれと似たような発想は、あの複雑奇怪なテンプレートに応用することも可能だ。このような結論ファーストの発想は、とりわけ冗長的な式や複雑で曖昧な式に対して有効なものとなる。

if (std::is_same<typename T<value_type>::value_type, value_type>::value);
↓
if (std::is_same<value_type, typename T<value_type>::value_type>::value);

以下は大したメリットではないが、第一引数に定数を記述するこれらのスタイルは、カリー化や部分適用を活用する際の記述との一貫性を保つ効果もある。

f = equal(99) // 部分適用
f(88)         // `equal(99, 88)`と同等の処理が行われる

function equal(a, b) {
   if (arguments.length == 1)
      return function (B) { return equal(a, B) }
   return a == b
}

言語設計への応用

ある性質を決定付ける重要な要素を文脈の先頭に記述するという発想は、命名規則やセマンティクスへの応用にも有効である。

例えば、Go言語では配列型をint[]ではなく[]intという形式で宣言する。これは非常に優れた発想である。逆にint[]による従来の文法でVeryLongTypeName[]のような型名の長い記述が用いられてしまうと、配列型という特性を理解するまでに時間が掛かってしまう。型名VeryLongTypeNameを認識した後に配列型を表す修飾子[]を認識することになるためだ。この順序の違いは認知コストの観点では実に効率が悪い。

逆に[]VeryLongTypeNameという形で[]を先頭に記述すれば、より直感的に配列型の記述であることが理解できるようになる。似たような発想はSwift言語やRust言語の配列型の記法([int])にも取り入れられており、こちらは語句の範囲を明確にできるという意味でも優れた記法となっている。

int[]は「int型の配列」と読めるが、[]intは「配列の型はint」となり読みづらいという意見もあるかもしれない。しかし認知のしやすさという観点で見れば[]intの利点もそれなりに理解できるはずだ。

他にも、PerlやRubyに取り入れられている条件式の後置記法などは、ヨーダセマンティクス的にも一考の価値がある。

function f(v) {
   return false if (!v);
   return false if (0 > v);
   while (v--) {
      break if (9 == v);
      print v;
   }
}

最後に

実のところヨーダ記法はマニアックな記法なのである。ヨーダ記法が世の中に受け入れられないのは、不自然な記法だという認識はもとより、ヨーダ記法のメリットがプログラマのセンスや思考方法など、感覚的な部分に依存しやすいことも一つの要因になっていると考えられる。

特に今回紹介したヨーダセマンティクスによる恩恵のほとんどは、一般的なプログラマには理解し難く、また必要性も感じられないものとなっているはずだ。

ヨーダ記法を真に理解するには、感覚やセンス、心の目、いわばフォースのようなものが必要なのかもしれない。

ヨーダ記法がプログラマの心の闇を映し出す鏡とならんことを。
May the Force be with you.
― YoSH

広告