blog.matsuby.work

コンテキストネイティブになろう

Category: プログラミング
Tags:

コンテキストは、普段は無意識に利用しているという点で、実は元からネイティブであるといっても過言ではないのだが、その機能性について理解しているわけではないため、プログラミングなどの会話以外の方面への応用がききづらい。

あえて分解してから理解し直すことで、強化されたコンテキストを自由に扱う真ネイティブを目指しましょう。

コンテキストについて

一般的には「背景」・「文脈」・「状況」・「前後関係」などのように訳される。

訳されると書いたものの、コンテキストはこれらの概念をまるっと飲み込んだ考え方なので、あくまでコンテキストはコンテキストとして捉える必要がある。例えば日本語の「自由」の意味を英語話者が理解したい場合、英語では「Liberty」と「Freedom」に分解される2種類の自由がブレンドされたものとして捉えるような感じだ。「自由」=「Liberty」として解釈した場合、「Freedom」側の「自由」の意味理解が片手落ちになってしまう。

ということで、コンテキストはどのようなものなのかについて、じっくりと考えてみよう。

  • コンテキストは共有可能な情報の一種である
  • コンテキストは至るところに、いつでも発生する
  • コンテキストは時間を通じて変化することがある
  • コンテキストは特定の範囲に対して有効である
  • コンテキストは暗黙的に発生する
  • コンテキストは見えづらい

コンテキストはある時、容赦なく襲いかかってくる。

どの山本くんか?

母「この前、山本くんのお母さんと会って話したんだけど〜」

ぼく「ちょっと待って、山本くんってどの山本くん?」

母「ほら、あなたが高校で一緒だった、背の高いラグビー部の子。」

ぼく「ああ、あの山本くんね。」

コンテキストの利点

コンテキストは情報にアクセスするためのショートカットである。

特に意識されずに、ほぼ日常的に使われている。

「山本くん」

「会社の近くのとんこつラーメン店」

「春に開催された飲み会」

など、いろいろなコトやモノに対してコンテキストが発生する。

対象が持つ情報(属性)は基本的には無限である。

「◯◯高校でYYYY年入学の、2年目にX組で一緒だったラグビー部の山本くん。今は〇〇市に住んでいて、昨年に結婚してお子さんが2人」

「◯◯駅の近く、◯◯通りの角にあるYYYY年から営業している、とんこつラーメン店”豚丸”。店主のこだわりが強く、1日かけてとんこつを煮込んでスープを作るのが特徴」

「YYYY年MM月DD日に開催された、Xさん主催のYさんとZさんがいた◯◯という居酒屋で行われた飲み会」

日常会話においては、「相手に何らかの情報を思い出させて、それを前提として次の会話に繋げる」という場面が多く発生する。

会話におけるコンテキストの役割は「相手が対象についての情報を知っているという前提条件を期待・利用して、対象を思い出すために必要最低限の情報を伝えることで、相手に対象を理解させる」ということになる。

つまり、「◯◯高校でYYYY年入学の、2年目にX組で一緒だったラグビー部の山本くん。今は〇〇市に住んでいて、昨年に結婚してお子さんが2人」といったフルフルの情報を伝えなくても、「高校の同級生の山本くん」にまで削ぎ落として、相手に伝えることが可能となる。嬉しい、やったー。

コンテキストの欠点

母「この前、山本くんのお母さんと会って話したんだけど〜」

ぼく「ちょっと待って、山本くんってどの山本くん?」

母にとっては「山本くん」=「ぼくの高校の同級生の山本くん」を前提に話しているが、ぼくが大学や社会人になって別の「山本くん」と出会ってしまった場合、「山本くん」は

「ぼくの高校の同級生の山本くん」

「ぼくの大学の同級生の山本くん」

「ぼくの会社の同僚の山本くん」

の3パターンのうちのどれか、ということになってしまって対象を一意に特定できない。

さらには高校の時に別のクラスにもう1人の山本くんがいたとすると、「ぼくの高校の同級生の山本くん」でも一意にならなくなってしまう。困った。

コンテキストは「情報量を削減する」という非常に便利な側面があるものの、人によって有している情報や経験に差が生じるため、ある時点では成立していたものが、時間の経過で新たなコンテキストが発生することによって成立しなくなってしまうことがある。あるいは、所属するコミュニティや文化が変わったことによって、今まで当たり前のように通じていたものが全く通じなくなってしまうといったことが発生する。

プログラミングにおけるコンテキストの応用

コンテキストは、システム、サブシステム、パッケージ、モジュール、ファイル、コンポーネント、クラス、関数、オブジェクト、if文 といったように、さまざまなレイヤーごとに発生する。

例えば、以下のように単純なUser型があるとする。

type User = {
  id: number;
  email: string;
};

この場合、フィールドのidやemailは、「User型というコンテキストの中に属している」状態になる。

つまり、Userという箱に入っている状態では、「ユーザーのID」や「ユーザーのemail」といった情報を指し示すものとなる。

一方で、Userという箱からidやemailだけが飛び出した場合、「ID」や「email」といったものになり、「ユーザーの」というコンテキストから離れ、情報は欠落する。

const user: User = {
  id: 42,
  email: "[email protected]",
};

console.info(user.id); // 「ユーザー」のID

const id = user.id; // 「ユーザーの」コンテキストから離れる

console.info(id); // ID

これは「高校の同級生の山本くん」からプレーンな「山本くん」として扱われることと似ている。

その場合、今まで考慮する必要のなかった「大学の同級生の山本くん」や「同僚の山本くん」の存在が浮上してくることになる。

同様に、情報の扱われ方が「ユーザーのID」から「ID」に変化したとすると、「会社ID」「画像ID」といったものを含んだ、より抽象的な概念と競合することになる。

例えば、以下のようなアバターを取得する関数があるとする。

function fetchAvator(id: number) {
  // 処理
}

const user: User = {
  id: 42,
  email: "[email protected]",
};

const avator = fetchAvator(user.id);

ある時、仕様の変更によってUserがcompanyに属するモデルに変更になったとして、companyIdがユーザーIDに追加された時のことを考えてみよう。

type User = {
  id: number;
  email: string;
  companyId: number; // <- 追加
};

「そうはならんやろ・・」とツッコミたくなるが、説明のために一旦以下のように変更してみる。

function fetchAvator(id: number) {
  // 処理
}

const user: User = {
  id: 42,
  email: "[email protected]",
  companyId: 21,
};

const id = user.companyId;

const avator = fetchAvator(id);

本来意図していたこととしては、fetchAvatorにはuser.idを渡すということだったので、このコードには実装上の問題がある。

しかし、上記のコード、論理的な矛盾は発生していない。なぜなら、「user.companyId」は「id」の一種なので、IDを名乗っても矛盾はないからだ。

問題はどこにあるのか?

ここではユーザーのコンテキストに対して変化が発生している。今まではユーザーのIDといえば1つを指していたものが、companyIdという別のIDが増えている。つまり2つは明確に分けて考える必要が出てきたのである。

function fetchAvator(userId: number) {
  // 処理
}

const user: User = {
  id: 42,
  email: "[email protected]",
  companyId: 21,
};

const userId = user.id;

const avator = fetchAvator(userId);

上記の例のように、わざわざuserIdを取り出して一時変数に入れるのはアホくさいので、実際の呼び出しは以下のようになるだろう。(あとは非同期関数にもなるはず)

const avator = fetchAvator(user.id);

先ほど見た例では

  • ある情報から特定の情報のみを独立させた場合、コンテキストが欠落する
  • コンテキストそのものに変化があった場合、今までの方法で対象を一意に指し示すことが難しくなることがある

といったケースを見てきた。

サンプルコードはかなり無理やり誘導する感じになってしまったが、ことプログラミングにおいては、情報を部分的に取り出して別の関数やモジュールに渡したり、仕様の変更によってモデルや前提条件とされていたものに変化が起きたりといったことが日常的に発生する。

日々の会話で無意識的にコンテキストが利用され、「情報量を削減する」ということに一役買っている、ということを説明したが、これはほぼ無意識的に行われるため、プログラミング上では意識されづらい。

コンテキストは、システム、サブシステム、パッケージ、モジュール、ファイル、コンポーネント、クラス、関数、オブジェクト、if文 といったように、さまざまなレイヤーごとに発生する。

実生活で考えてみると、日本のコンテキスト、東京のコンテキスト、会社のコンテキスト、課のコンテキスト、家族内のコンテキスト、特定の友人とのコンテキスト、飲み会に参加した人同士のコンテキスト、同じテレビ番組を見た人のコンテキスト、ワンピースのコンテキストなど、さまざまな種類のコンテキストがある。

また、コンテキストは他のコンテキストを包含することもある。例えば、日本のコンテキストは東京のコンテキストを包含し、会社のコンテキストは課のコンテキストを包含する。課のコンテキストでは単に「山田さん」で1人を指し示せていたものが、会社のコンテキストまで広げると「営業部2課の山田さん」のように、追加で情報が必要になるケースがある。

プログラミングにおけるコンテキストは、上位レイヤーが下位レイヤーを包含する。これはファイルシステムとしてはディレクトリ階層がその役割を担う。

世界
├── 日本
│   ├── 東京都
│   │   ├── 中央区
│   │   └── 港区
│   ├── 北海道
│   │   ├── 札幌市
|   |   |   └── 中央区
│   │   └── 網走市
│   └── 沖縄県
│       ├── 那覇市
│       └── 石垣島
└── アメリカ
    ├── ニューヨーク州
    ├── カリフォルニア州
    └── ワイオミング州

例によってかなり適当な図となるが、ディレクトリ階層からコンテキストの関係性を見てみよう。

「中央区」と「港区」は「東京都」のコンテキストに属していて、「北海道」には属していない。

「東京都」と「北海道」と「沖縄県」は「日本」のコンテキストに属していて、「アメリカ」には属していない。

「東京都」のコンテキストでは「中央区」は一意に通じるが、日本のコンテキストでは一意にならない。(札幌市や横浜市など、さまざまな「中央区」が存在するため)

そのため、「東京の中央区」を「中央区」として「北海道」のコンテキストに持ち出した場合、札幌の「中央区」として解釈されるおそれがある。

つまり、「東京都の中央区」を東京都の外側のコンテキストに持ち出す場合は、「中央区」ではなく、「東京都の中央区」として持ち出す必要がある。

次に「東京都」を「アメリカのニューヨーク州」のコンテキストに持ち出すことを考えてみると、このケースでは特に問題は発生しにくいだろう。「日本の首都が十分に知られていること」「日本以外に東京都が存在しないこと」を前提とすれば「東京都」だけで「日本の東京都」であることは容易に想像ができる。

一方で「東京都」を「アメリカのワイオミング州」のコンテキストに持ち出した場合は、「日本の首都が十分に知られていること」という前提が弱くなるので、その場合は「日本の東京都」として持ち出す必要があるかもしれない。

これまで見てきたように、コンテキストに応じて、あるものを指し示すために必要な情報量に差が出てくる。コンテキストを意識せずに情報を調整しない場合、正しく伝えることができないか、過度な情報伝達となってしまう。例えば「東京都」のコンテキストで「世界の国の1つである日本の東京都の中央区」という表現は正しいものの冗長な表現となる。

また、ファイルの内側の「クラス、関数、オブジェクト、if文」などのコンテキストについては、ほとんどプログラミング言語の「スコープ」と同じという認識で良いだろう。正確には、スコープが発生するところにコンテキストが背景情報として隠れている、といった形になる。

// ファイルのコンテキスト
const EXAMPLE = 42;

function f(isXXX: boolean) {
  // fのコンテキスト
  const local = 42

  if (isXXX) {
    // isXXX=true のコンテキスト
  } else {
    // isXXX=false のコンテキスト
  }

  // 再びfのコンテキスト
}

function g() {
  // gのコンテキスト
}

ここで注意しておきたいこととしては、「関数f」と「関数g」は同じコンテキスト(ファイル)に属しているものの、内部の処理についてはそれぞれ別のコンテキストを有しているということだ。つまり、「関数f」は「関数g」のインターフェースについては知っているが、「関数g」の中で実際にどのような処理が行われるのかという具体的な知識を持ってはいけない。

次に、「複雑なif文はなぜ読むのが辛いのか」「早期リターンがなぜ好まれるのか」についても合わせて考えてみよう。

早期リターンを書こう」より

const today = new Date(2021, 10, 9)

function isActiveUser(user) {
    if (user != null) {
        if (user.startDate <= today && (user.endDate == null || today <= user.endDate)) {
            if(user.stopped) {
                return false;
            } else {
                return true;
            }
        } else {
            return false;
        }
    } else {
        return false;
    }
}

↓ 早期リターンを使った場合

function isActiveUser(user) {
    if (user == null) { 
        return false;
    }

    if (today < user.startDate) { 
        return false;
    }

    if (user.endDate != null && user.endDate <= today) {
        return false;
    }

    if(user.stopped) {
        return false;
    }

    return true;
}

ここでの変化については

  • 見た目としてネストのレベルが下がって、読みやすくなった
  • else節がなくなったため、「ある条件の逆の状態」を考える必要がなくなった
  • 結果として、「activeではない条件」だけを考えれば良くなった

といったことがいえると思う。元の処理の読みづらさの元となっているのは、そのコンテキストの理解しづらさに起因している。

特に「ifに対応するelseの距離が遠い」場合、「ifの条件の逆を評価するタイミングが遅くなる」ということが発生する。if内の処理を読み終わった後で、elseにぶち当たったら、元のif文をもう一度読んで、その逆を考える、という作業になる。

function isActiveUser(user) {
    if (user != null) {
        if (user.startDate <= today && (user.endDate == null || today <= user.endDate)) {
            if(user.stopped) {
                return false;
            } else {
                return true;
            }
        } else {
            return false; // <- ここの状態は?
        }
    } else {
        return false; // <- ここの状態は?
    }
}

脳内メモリが潤沢に使えるぜという人は、頭の中にスタック型のデータとしてif文を記憶しておいて、elseに当たったときにifの情報を取り出す、ということも理論上は可能であるが、早期リターンなどの工夫を凝らすことによって、皆が読みやすいコードになるのであれば、そちらの方がハッピーだ。

ところで、実は早期リターンを使った場合でも、コンテキストは以下のように変化を続けている。

function isActiveUser(user) {
    // コンテキスト①
    if (user == null) { 
        return false;
    }

    // コンテキスト②
    // !(user == null)

    if (today < user.startDate) { 
        return false;
    }

    // コンテキスト③
    // !(user == null) && !(today < user.startDate)

    if (user.endDate != null && user.endDate <= today) {
        return false;
    }

    // コンテキスト④
    // !(user == null) && !(today < user.startDate) && !(user.endDate != null && user.endDate <= today)

    if(user.stopped) {
        return false;
    }

    // コンテキスト⑤
    // !(user == null) && !(today < user.startDate) && !(user.endDate != null && user.endDate <= today) && !(user.stopped)

    return true;
}

早期リターンのif文を通過する度に、コンテキストには通過したif文とは逆の条件が増えていくのである。

だが、これは実際にはほとんど脳内に記憶しておく必要のないコンテキストである。記憶しておく必要のあるのは!(user == null) の部分くらいで、それ以外の各条件は互いに影響しあうことがない。そのため、脳に負荷をかけにくい構造となっている。

また、letよりもconstを優先する理由も、早期リターンを利用する理由と少しだけ似ている。

letの場合、再代入可能なので、コンテキストを跨いでデータが変化する可能性が高い。 コンテキストを跨いでデータが変化するということは、コンテキストが変化した状態で、さらにデータの変化まで扱うので、複雑さが生まれやすい。若干の小泉構文っぽさが否めないことはさておき、逆方向から考えてみると、データの変化を追跡したくなった場合に、コンテキストの変化を考慮する必要が生じるということになる。

一方constの場合、再代入不可なので、コンテキストを跨いで情報が変化する可能性が低い。 「ある時点におけるデータは、その後変更されることがない」ということが保証できれば、「そのデータが発生した時のコンテキストだけを考えれば良い」ということになるので、少し楽になるのだ。

そのため、特段letを利用する必要がなければ、常にconstを選ぶのが常套手段である。

まとめ

  • コンテキストは普段無意識に利用している「情報量削減」のための手段
  • コンテキストは人や場所によって異なったり、コンテキスト自身が変化することがある
    • コンテキストを扱う場所やコンテキスト自身が変化することによって、情報の一意性がなくなることがある
  • プログラミングやデータにおいても、レイヤーごとにコンテキストが発生する
  • 複雑なコンテキストを扱うと、可読性が下がって認知負荷が高くなる