この記事は、Rust Advent Calendar 2025 6日目のエントリ記事です。
はじめに
「それって、○○みたいなものですよね」
私は、この言葉に何度救われてきただろう。新しい概念を理解するとき。誰かに説明するとき。問題を解決するとき。類推は、私の思考の基盤だった。いや、今でも基盤だ。ただ、その基盤が思ったほど頑丈ではなかったことを、私は何度も思い知らされてきた。
Rustを学び始めた頃の話だ。Rustは、プログラミング言語の1つだ。安全で高速なプログラムを書けることで知られている。私はRustの公式教科書「The Rust Programming Language」を読んでいた。所有権の章に差し掛かったとき、こんな説明に出会った。
Rustには「所有権(ownership)」という独特の概念がある。少し専門的な話になるが、プログラムを書くとき、データはコンピュータの「メモリ」という場所に保存される。メモリは有限だから、使い終わったデータは片付けなければならない。片付けを忘れると、メモリがいっぱいになって動かなくなる。逆に、まだ使っているデータを間違えて片付けてしまうと、プログラムが壊れる。
多くのプログラミング言語では、この「いつ片付けるか」の管理をプログラマーに任せるか、自動で行うかのどちらかだ。Rustは第三の道を選んだ。「所有権」というルールで、コンパイル時(プログラムを実行する前)に安全性を保証する。
ルールはシンプルだ。メモリ上のデータには、必ず1つの「所有者」となる変数が存在する。そして、その値を別の変数に渡すと、所有権が移動(move)する。移動した後は、元の変数からはアクセスできなくなる。所有者がいなくなったデータは、自動的に片付けられる。これがRustの基本ルールだ。
(注:この先、コード例が続きます。プログラミングに詳しくない方は、コードの詳細を読み飛ばしても大丈夫です。「類推で理解したつもりになったが、実際は違った」という体験談として読んでいただければ、本記事の主旨は伝わります。)
私は頭の中で、勝手に類推を作り上げた。「なるほど、本の貸し借りみたいなものか。本を誰かに貸したら、自分の手元にはない。返してもらうまで読めない」。教科書にそう書いてあったわけではない。私が勝手にそう解釈した。
この類推で、所有権の基本は理解できた気がした。コンパイラが怒る理由もわかった。moveが起きる場面も予測できるようになった。私は満足した。「そういうことか」と納得して、次の章に進んだ。
しかし、しばらくして困難に直面した。
私がやりたかったのは、こういうことだ。本棚に本がある。本を誰かに貸す。貸した本が何かを覚えておきたい。現実世界では当たり前のことだ。これをコードで書こうとした。
// 私が書こうとしたコード(コンパイルエラー) struct BookShelf { books: Vec<String>, lent_to: Option<&String>, // 貸した本への参照を持ちたい }
Rustでは、所有権を完全に移動させずに、一時的にデータを「見せる」だけの仕組みがある。これを「参照(reference)」や「借用(borrow)」と呼ぶ。&Stringは「Stringへの参照」を意味する。所有権は移動しない。ただ、一時的に覗き見できるだけだ。
「本の貸し借り」の類推で考えれば、これは自然なはずだった。本棚には本がある。本を誰かに貸したら、貸した本への参照を持っておく。でも、Rustはこのコードを許さない。
error[E0106]: missing lifetime specifier
「ライフタイム」。また新しい概念だ。
なぜライフタイムが必要なのか。参照は、データの「場所」を覚えている。でも、その場所にあったデータが消えてしまったらどうなるか。参照だけが残って、参照先には何もない。存在しないデータを指す参照。これは危険だ。
だから、Rustは参照の「寿命」を追跡する。参照が有効な間は、参照先のデータも存在していなければならない。この寿命を明示するのが、ライフタイムだ。
ライフタイムを指定すればいいのか。私は格闘した。
// ライフタイムを追加してみる struct BookShelf<'a> { books: Vec<String>, lent_to: Option<&'a String>, }
コンパイルは通る。貸し出しもできる。
let mut shelf = BookShelf { books: vec![String::from("Rust Book"), String::from("Programming Rust")], lent_to: None, }; shelf.lent_to = Some(&shelf.books[0]); println!("貸し出し中: {:?}", shelf.lent_to); // => 貸し出し中: Some("Rust Book")
でも、本棚に新しい本を追加しようとすると、地獄が始まる。
shelf.books.push(String::from("New Book"));
error[E0502]: cannot borrow `shelf.books` as mutable because it is also borrowed as immutable
--> src/main.rs:22:5
|
18 | shelf.lent_to = Some(&shelf.books[0]);
| ----------- immutable borrow occurs here
...
22 | shelf.books.push(String::from("New Book"));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
23 | println!("新しい本を追加: {:?}", shelf.books);
| ----------- immutable borrow later used here
「本を貸している間は、本棚に新しい本を追加できない」。現実世界ではありえない制約だ。なぜこんなに難しいのか。
私は「本の貸し借り」で考え続けた。貸している間も本棚にどの本があるかは覚えている。本棚に新しい本を追加することと、貸した本を追跡することは、まったく独立した操作のはずだ。なのに、なぜRustはそれを許さないのか。
長い時間をかけて、やっと気づいた。私の類推が間違っていた。
ここで「借用チェッカー(borrow checker)」の話をしなければならない。Rustには、コンパイル時にメモリ安全性を検証する仕組みがある。これが借用チェッカーだ。
借用チェッカーの基本ルールはシンプルだ。「参照が有効な間は、参照先のデータを変更してはならない」。
なぜこんなルールがあるのか。Vec(可変長配列)の仕組みを考えてみよう。Vecは、内部的には連続したメモリ領域にデータを格納している。本棚でいえば、横一列に並んだ棚だ。最初に5冊分のスペースを確保したとする。6冊目を追加したいとき、どうなるか。
今の棚には入らない。だから、より大きな棚を用意して、5冊をすべて移動させる。そして6冊目を追加する。これがVecの動作だ。
ここで問題が起きる。移動前の棚の位置を覚えている参照があったとする。本を移動した後、その参照はどこを指すのか。もう本がない場所だ。空っぽの棚を指している。これが「ダングリングポインタ」と呼ばれる危険な状態だ。存在しないデータへの参照。アクセスしたら、何が起きるかわからない。
だから、Rustは「参照がある間は変更禁止」というルールを強制する。
現実の本の貸し借りには、この問題は存在しない。本棚のサイズを変えても、貸した本が消えることはない。でも、コンピュータのメモリでは、Vecが成長するときにデータが移動する。「本」という類推が、私の理解を助けると同時に、私の理解を歪めていた。
正しい設計は、参照ではなくインデックスや識別子を使うことだった。
// アプローチ1: インデックスで管理 struct BookShelf { books: Vec<String>, lent_index: Option<usize>, } let mut shelf = BookShelf { books: vec![String::from("Rust Book")], lent_index: None, }; shelf.lent_index = Some(0); // インデックスを記録 shelf.books.push(String::from("New Book")); // これは動く! println!("貸し出し中: {:?}", shelf.books.get(shelf.lent_index.unwrap())); // => 貸し出し中: Some("Rust Book")
// アプローチ2: 所有権を完全に移動 struct BookShelf { books: Vec<String>, } struct LentBook { book: String, // 所有権ごと移動 borrower: String, } let mut shelf = BookShelf { books: vec![String::from("Rust Book"), String::from("Programming Rust")], }; let lent = LentBook { book: shelf.books.remove(0), // 本棚から取り出す borrower: String::from("Alice"), }; shelf.books.push(String::from("New Book")); // 本棚は自由に変更できる
「本の貸し借り」という類推は、入り口としては正しかった。でも、その類推を引きずりすぎた。Rustにおける「借用」は、現実世界の「貸し借り」とは違う。借用(&T)は「一時的に見せる」だけで、「貸した相手を追跡する」仕組みではない。そして、借用中はデータの変更ができない。この違いに気づくまでに、私は何週間も費やした。
類推は、両刃の剣だ。
思い返せば、これは初めての失敗ではなかった。
非同期処理を学んだときも、同じ罠にはまった。
(注:ここからも技術的な話が続きます。コードの詳細は読み飛ばしても、「料理の類推で考えたら、実際の挙動と違った」という話として理解できます。)
まず、非同期処理とは何かを説明しておこう。日常生活で考えてみよう。洗濯機を回している間、あなたは洗濯機の前でじっと待っているだろうか。たぶん、その間に別のことをしているはずだ。掃除をしたり、料理を作ったり。洗濯機が終わったら、干しに行く。これが「非同期」の発想だ。
プログラムも同じだ。通常のプログラムは、1つの処理が終わるまで次の処理に進めない。ファイルを読み込んでいる間、プログラムは待っている。ネットワークからデータを取得している間も、待っている。これでは効率が悪い。「待っている間に、別のことをやろう」。これが非同期処理だ。
私は類推を作り上げた。「非同期処理は、料理を並行して作るようなものか」。パスタを茹でている間にソースを作る。オーブンで肉を焼いている間にサラダを準備する。待ち時間を有効活用して、全体の調理時間を短縮する。この類推で、Rustのasync/await構文の基本は理解できた。
async fn cook_dinner() { let pasta = boil_pasta(); // パスタを茹で始める let sauce = make_sauce().await; // ソースを作る(待つ) let pasta = pasta.await; // パスタが茹で上がるのを待つ serve(pasta, sauce); }
問題は、「共有リソース」にアクセスするコードを書いたときだった。
共有リソースとは何か。料理の例で考えよう。キッチンには、コンロが1つしかない。2人の料理人が、同時にそのコンロを使いたいとする。どうなるか。1人が使っている間、もう1人は待つしかない。
プログラムでも同じことが起きる。データベース接続、ファイル、あるいはメモリ上のデータ構造。複数の処理が同時に1つのリソースにアクセスしようとすると、混乱が起きる。だから、「Mutex(ミューテックス)」という仕組みで順番を管理する。1つの処理がMutexを「ロック」したら、他の処理はロックが解除されるまで待たなければならない。
use std::sync::Arc; use tokio::sync::Mutex; struct Kitchen { stove: Arc<Mutex<Stove>>, // コンロは1つしかない(Mutexで保護) } async fn cook_two_dishes(kitchen: &Kitchen) { let stove = kitchen.stove.clone(); // 2つの料理を「並行して」作ろうとする let dish1 = tokio::spawn({ let stove = stove.clone(); async move { let mut s = stove.lock().await; // コンロを確保 cook_on_stove(&mut s).await; // 10分かかる } }); let dish2 = tokio::spawn({ let stove = stove.clone(); async move { let mut s = stove.lock().await; // コンロを確保しようとする cook_on_stove(&mut s).await; // ...が、dish1が終わるまで待つ } }); let _ = tokio::join!(dish1, dish2); }
私は「並行して料理を作る」と思っていた。2つの料理を同時に調理して、時間を半分にできるはずだと。でも、コンロは1つしかない。片方がコンロを占有している間、もう片方は待っていた。実行してみると、こうなる。
[メインコンロ] パスタ の調理を開始 [メインコンロ] パスタ の調理が完了 [メインコンロ] ソース の調理を開始 [メインコンロ] ソース の調理が完了 合計調理時間: 4.00秒
各料理2秒なら、並行処理で2秒のはずだった。でも、4秒かかった。並行処理の意味がなかった。
なぜこうなるのか。現実の料理で考えてみよう。実際のキッチンでは、Aさんがコンロの左側でパスタを茹でている間、Bさんが右側でソースを温められる。コンロには複数の口がある。だから、2人が同時に調理できる。
でも、私のコードでは、コンロを「1つのもの」としてMutexで保護していた。「コンロ全体」をロックしていた。だから、1人がコンロを使っている間、もう1人はコンロの前で待つしかなかった。
これが「排他制御」の現実だ。Mutexで保護された共有リソースは、一度に1つのタスクしかアクセスできない。「料理を並行して作る」という類推には、この排他制御の概念が含まれていなかった。私の頭の中のキッチンには、コンロの口がいくつもあった。でも、コードの中のキッチンには、コンロが1つしかなかった。
より厄介な問題もあった。「デッドロック」だ。
デッドロックとは何か。日常の例で説明しよう。
AさんとBさんが、食事をしようとしている。テーブルには、ナイフとフォークが1本ずつしかない。食事をするには、両方が必要だ。Aさんは先にナイフを取った。Bさんは先にフォークを取った。
Aさんは思う。「フォークがほしい。Bさんが手放すまで待とう」。Bさんも思う。「ナイフがほしい。Aさんが手放すまで待とう」。
どちらも、自分が持っているものを手放さない。どちらも、相手が手放すのを待っている。永遠に。これがデッドロックだ。
async fn prepare_meal(kitchen: &Kitchen) { // タスク1: まずコンロを確保、次にオーブンを確保 let task1 = async { let _stove = kitchen.stove.lock().await; tokio::time::sleep(Duration::from_millis(10)).await; let _oven = kitchen.oven.lock().await; // オーブンを待つ // ... }; // タスク2: まずオーブンを確保、次にコンロを確保 let task2 = async { let _oven = kitchen.oven.lock().await; tokio::time::sleep(Duration::from_millis(10)).await; let _stove = kitchen.stove.lock().await; // コンロを待つ // ... }; tokio::join!(task1, task2); // 永遠に終わらない }
タスク1がコンロを持ってオーブンを待ち、タスク2がオーブンを持ってコンロを待つ。お互いが相手を待ち続けて、永遠に進まない。実行してみると、こうなる。
[タスク1] コンロを確保しました! [タスク2] オーブンを確保しました! [タスク1] オーブンを確保しようとしています... [タスク2] コンロを確保しようとしています... ⚠️ タイムアウト!デッドロックが発生しました。
現実の料理では、こんなことは起きない。「ちょっとナイフ貸して」と声をかければ済む。あるいは、「先にフォーク使っていいよ」と譲り合える。人間には、コミュニケーションがある。
でも、コンピュータのスレッドは声をかけない。ロックを取得したら、自分の処理が終わるまで手放さない。相手が待っていることすら知らない。だから、永遠に待ち続ける。
この問題のデバッグに、私は丸一日を費やした。プログラムが動かない。エラーも出ない。ただ、止まっている。「なぜプログラムが止まるのかわからない」と頭を抱えた。料理の類推では、デッドロックという概念自体が存在しなかったからだ。キッチンで誰かと道具の取り合いになっても、最終的にはどちらかが譲る。でも、プログラムは譲らない。
また同じ失敗をしている。私は少し落ち込んだ。でも、まだ終わりではなかった。
データベースのトランザクションでも、同じ失敗をした。
(注:ここでも技術的な話が続きます。「銀行振込の類推で考えたが、実際のシステムはもっと複雑だった」という話として読んでいただければ大丈夫です。)
まず、トランザクションとは何かを説明しよう。日常生活で例えてみる。あなたがコンビニでおにぎりを買うとする。この「買い物」という行為は、2つのことが同時に起きなければ成立しない。「あなたがお金を払う」と「店があなたにおにぎりを渡す」。お金だけ払っておにぎりがもらえなかったら困る。おにぎりだけもらってお金を払わなかったら、それは万引きだ。両方が成功するか、両方が起きないか。どちらかでなければならない。
データベースでも同じだ。銀行の振込を考えよう。Aさんの口座から1万円を引いて、Bさんの口座に1万円を足す。この2つの操作は、両方成功するか、両方失敗するか、どちらかでなければならない。Aさんから引いたのにBさんに足されなかったら、1万円が消えてしまう。
このような「ひとまとまりの操作」を保証する仕組みがトランザクションだ。途中で失敗したら、最初の状態に戻す(ロールバック)。すべて成功したら、確定する(コミット)。
私は類推を作り上げた。「トランザクションは、銀行の振込みたいなものか」。この類推で、データベースの基本的な特性は理解できた。
BEGIN TRANSACTION; UPDATE accounts SET balance = balance - 10000 WHERE user_id = 'A'; UPDATE accounts SET balance = balance + 10000 WHERE user_id = 'B'; COMMIT;
問題は、トランザクションが失敗したときの処理を書いたときだった。
async fn transfer_money( pool: &PgPool, from: &str, to: &str, amount: i64, ) -> Result<(), Error> { let mut tx = pool.begin().await?; // 送金元の残高を減らす sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE user_id = $2") .bind(amount) .bind(from) .execute(&mut *tx) .await?; // 外部APIを呼び出して送金通知を送る(これが問題) notify_transfer(from, to, amount).await?; // 送金先の残高を増やす sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE user_id = $2") .bind(amount) .bind(to) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) }
外部APIの呼び出しが失敗したら、トランザクションはロールバックされる。データベースの状態は元に戻る。完璧だと思った。
でも、ある日、こんなシナリオを考えた。外部APIの呼び出しが成功した後、2番目のUPDATE文が失敗したらどうなるか。
順番を追ってみよう。まず、送金元の残高を減らす。成功。次に、送金通知を送る。成功。通知は、もう相手に届いている。最後に、送金先の残高を増やす。ここで失敗。アカウントが凍結されていた。
トランザクションはロールバックされる。データベースの残高は元に戻る。でも、通知は?もう送ってしまった。取り消せない。
実際にPostgreSQLで検証してみた。
--- シナリオ: 外部API成功後にDB更新が失敗 --- (Alice → frozen_account: 5,000円 - 受取人アカウント凍結で失敗) → 通知を送信しました(外部API呼び出し) 送金失敗: 受取人のアカウントが凍結されています 残高: alice: Alice (90000円) ← 変わっていない bob: Bob (60000円) 送金通知: 2件 alice → bob: 10000円 alice → frozen_account: 5000円 ← 通知は送信された!
トランザクションはロールバックされる。データベースの残高は元に戻る。でも、送金通知はすでに送られている。「5,000円送金しました」という通知が届いているのに、実際には送金されていない。
銀行の振込では、こんなことは起きない。なぜか。銀行では、振込処理と通知は同じシステムの中で一貫して管理されている。「お金を動かす」と「通知を送る」が、一体の操作として設計されている。
でも、私が書いたコードはそうではなかった。データベースと、通知を送るサービスは、別々のシステムだった。データベースのトランザクションは、データベースの中だけを巻き戻せる。外部サービスへの呼び出しは、トランザクションの外にある。ロールバックしても、すでに送った通知は取り消せない。
これが「分散システム」の難しさだ。複数のシステムにまたがる操作を、一貫して管理することは、想像以上に難しい。
より厄介な問題もあった。ロールバック自体が失敗することがあるのだ。
async fn complex_operation(pool: &PgPool) -> Result<(), Error> { let mut tx = pool.begin().await?; // 複数のテーブルを更新 update_table_a(&mut tx).await?; update_table_b(&mut tx).await?; update_table_c(&mut tx).await?; // ここで失敗 tx.commit().await?; Ok(()) } // update_table_c()が失敗すると、txはドロップされてロールバックされる // ...はずだが、ネットワーク障害でロールバックも失敗したら?
銀行の振込では、「振込を取り消す」という操作は確実に成功する。窓口で「やっぱりやめます」と言えば、それで終わりだ。
でも、コンピュータの世界では、ロールバック自体がネットワーク障害やデータベースクラッシュで失敗することがある。「元に戻す」という操作が、途中で止まる。そうなると、データは中途半端な状態で残る。Aさんから引かれたのに、Bさんには足されていない。1万円が宙に浮いている。
この問題に気づいたのは、本番環境で実際に起きてからだった。ユーザーからの問い合わせで発覚した。「送金したのにお金が届いていない」。調べてみると、ネットワーク障害でロールバックが完了していなかった。「銀行の振込みたいなもの」という類推が、分散システムの複雑さを覆い隠していた。銀行の振込は、何十年もかけて作り上げられた堅牢なシステムの上で動いている。私のコードは、そうではなかった。
いつになったら学習するのだろう。私は自分に問いかけた。でも、失敗はまだ続いた。
キャッシュでも、同じパターンだった。
(注:最後の技術的な事例です。「辞書を手元に置いておく類推で考えたが、実際はもっとややこしかった」という話です。)
まず、キャッシュとは何かを説明しよう。日常生活で考えてみる。あなたは仕事中、よく使うファイルをどこに置いているだろうか。毎回、会社の書庫まで取りに行くだろうか。たぶん、よく使うファイルは自分の机の上に置いているはずだ。すぐ手に取れるから。これがキャッシュの発想だ。
プログラムの世界でも同じだ。データベースからデータを取得するのは、時間がかかる。ネットワーク越しに問い合わせて、データベースが検索して、結果を返す。毎回これをやると遅い。だから、一度取得したデータを「手元」に保存しておいて、次からはそれを使う。これがキャッシュだ。
私は類推を作り上げた。「キャッシュは、よく使うものを手元に置いておくことか」。辞書を引くとき、毎回本棚まで行くのは面倒だ。よく使う辞書は、机の上に置いておく。机の上にあれば、すぐに引ける。この類推で、キャッシュの基本は理解できた。
use std::collections::HashMap; use std::sync::RwLock; struct UserCache { cache: RwLock<HashMap<UserId, User>>, } impl UserCache { async fn get_user(&self, id: UserId, db: &Database) -> User { // まずキャッシュを確認 if let Some(user) = self.cache.read().unwrap().get(&id) { return user.clone(); } // なければDBから取得 let user = db.fetch_user(id).await; // キャッシュに保存 self.cache.write().unwrap().insert(id, user.clone()); user } }
問題は、データが更新されたときだった。
async fn update_user_email( cache: &UserCache, db: &Database, id: UserId, new_email: String, ) -> Result<(), Error> { // DBを更新 db.update_email(id, &new_email).await?; // キャッシュを無効化 cache.cache.write().unwrap().remove(&id); Ok(()) }
これで十分だと思った。データを更新したら、キャッシュから削除する。次にアクセスしたときは、DBから最新のデータを取得する。シンプルで、正しいはずだった。
でも、ある問題が起きた。「競合状態(race condition)」だ。
競合状態とは何か。例え話で説明しよう。
あなたと同僚が、同時に同じ辞書を使おうとしている。あなたは辞書で「apple」を調べている。その間に、同僚が辞書の「apple」の項目に付箋を貼った。あなたが辞書を閉じて、もう一度開くと、付箋が貼ってある。これは問題ない。
でも、こういうケースはどうか。あなたが辞書の「apple」のページをコピーしている間に、同僚が辞書の「apple」の項目を書き換えた。そして、あなたがコピーを終えて、そのコピーを棚にしまった。棚にあるのは、古い情報のコピーだ。
これが競合状態だ。複数の処理が同時に動いているとき、その「順番」によって結果が変わってしまう。どの処理が先に終わるかは、そのときの負荷やネットワーク状況で変わる。だから、結果が予測できない。
時刻T1: リクエストAがget_user()を呼ぶ 時刻T1: リクエストAがキャッシュを確認 → ない 時刻T2: リクエストAがDBからuser(email="old@example.com")を取得 時刻T3: リクエストBがupdate_user_email()を呼ぶ 時刻T3: リクエストBがDBを更新(email="new@example.com") 時刻T4: リクエストBがキャッシュを削除 時刻T5: リクエストAがキャッシュに古いデータを保存(email="old@example.com")
何が起きたのか、順番に見てみよう。
リクエストAは、DBから古いデータを取得した。でも、キャッシュに保存する前に、一瞬待たされた。CPUが他の処理をしていたのかもしれない。ネットワークが混んでいたのかもしれない。
その隙に、リクエストBがやってきた。リクエストBは、DBのデータを更新した。そして、キャッシュを削除した。「これで、次にアクセスしたときは最新のデータが取得される」と。
でも、リクエストAはまだ終わっていなかった。リクエストAは、さっき取得した古いデータを、キャッシュに保存した。リクエストBが削除した後のキャッシュに。
結果、キャッシュには古いデータが入った。DBには新しいデータがある。キャッシュとDBで、データが食い違っている。
実行してみると、こうなる。
[T1] リクエストA: get_user()開始
[キャッシュ] ミス
[T2] リクエストA: DBから取得中...
[DB] 取得完了: email="old@example.com"
[T3] リクエストB: update_user_email()開始
[DB] メール更新: old@example.com -> new@example.com
[T4] リクエストB: キャッシュ無効化
[T5] リクエストA: キャッシュに保存
[キャッシュ] 保存: email="old@example.com" ← 古いデータ!
--- 結果確認 ---
DBの値: email=Some("new@example.com")
キャッシュの値: email=Some("old@example.com")
⚠️ キャッシュに古いデータが残っている!
結果、キャッシュには古いデータが残り続ける。
現実世界の辞書では、こんなことは起きない。なぜか。辞書の内容は、めったに変わらない。そして、辞書を使うのは通常1人だ。複数人が同時に同じ辞書を書き換えながら参照することは、まずない。
でも、コンピュータのデータは違う。複数のプロセスが、同時に、同じデータを読み書きする。しかも、ネットワーク遅延やCPUスケジューリングで、処理の順序が予測できない。「Aが先に終わるはず」と思っても、実際にはBが先に終わることがある。
この問題をデバッグするのに、3日かかった。「たまにデータが古いままになる」という報告を受けて、最初はDBの問題だと思った。DBを調べた。問題なかった。次にキャッシュの設定を調べた。問題なかった。ログを細かく分析して、やっと気づいた。タイミングの問題だった。特定の順番で処理が実行されたときだけ、問題が起きていた。
「手元に置いておく」という類推は、キャッシュの無効化タイミングの複雑さを完全に見落としていた。机の上の辞書は、勝手に内容が変わらない。でも、キャッシュの中のデータは、いつ古くなるかわからない。Phil Karltonの有名な言葉がある。「コンピュータサイエンスで難しいことは2つしかない。キャッシュの無効化と、名前付けだ」。この言葉の意味を、私は身をもって理解した。
どれも、類推としては間違っていない。でも、類推が示す以上のことを、私は類推から読み取ってしまっていた。
類推は、理解を助ける。しかし、誤解も生む。類推は、新しい視点を与える。一方で、本質を見えなくもする。類推は、創造の源泉だ。同時に、思考停止の入り口でもある。
これらの経験以来、私は類推について考え続けてきた。エンジニアとして、類推をどう使い分けるべきか。いつ類推すべきで、いつ類推を断つべきか。類推の力を活かしながら、その罠に落ちないためには、何が必要なのか。
そして、もう1つ気づいたことがある。類推は、単なる思考ツールではない。それは、人間の知能の根幹だ。 われわれは、あまりにも無意識に類推的な考え方をしながら日々を過ごしている。だからこそ、類推の限界を知ることが、これほど重要なのだ。
これは、類推に救われてきた人間が、類推に何度も裏切られた話だ。そして、それでもなお類推を手放せない人間が、類推とどう向き合うかを考えた記録だ。
類推とは何か
まず、類推とは何かを明確にしておきたい。
類推(アナロジー)とは、2つの異なる領域の間に構造的な類似性を見出し、一方の知識を他方に適用する思考法だ。AとBは表面的には違うが、その関係性の構造は似ている。だから、Aで学んだことを、Bに応用できる。
私は、類推こそが人間の思考の根幹だと考えている。論理的思考も、批判的思考も、創造的思考も、よく見ると類推が基盤にある。われわれは類推なしには、新しいことを考えることすらできない。
ソフトウェアエンジニアなんて、類推だらけだ。コードを読んでいると、「あ、これ、あのコードと同じ構造だな」と気づく。設計を考えていると、「前のプロジェクトのあのパターンが使えそうだ」と気づく。バグを追っていると、「この挙動、前にも見たことがある」とピンとくる。私たちは、毎日、無意識に類推している。自分でも気づかないうちに。
プログラミングを学ぶとき、類推を使っている。「変数は、ラベル付きの箱みたいなものだ」と教わる。値を入れて、取り出す。この類推があるから、抽象的な概念を具体的にイメージできる。新しいデータベースを学ぶとき、類推を使っている。「PostgreSQLのMVCCは、MySQLのInnoDBと似ているか」と考える。この類推があるから、ゼロから学ぶより速く理解できる。新しい言語を学ぶとき、類推を使っている。「Rustのtraitは、Goのinterfaceみたいなものか」と考える。完全に同じではないが、入り口にはなる。
われわれの頭の中では、常に類推が働いている。既知の世界での関係づけから、未知の関係づけを推論している。
物語を読むときも、私たちは類推している。登場人物の経験を自分の人生に重ね、フィクションの世界から現実への教訓を引き出す。主人公が困難を乗り越える姿を見て、自分の状況に当てはめる。異なる時代や文化を舞台にした物語から、普遍的な人間の営みを感じ取る。共感とは、つまり類推だ。「この人の気持ちは、あのときの自分の気持ちに似ている」。そう感じるから、私たちは物語に心を動かされる。
類推がなければ、われわれは毎回ゼロから学ばなければならない。新しいフレームワークに出会うたび、過去の経験が役に立たない。累積的な学習ができない。技術も発展しない。
だから、類推は人間の知能の基盤であり、思考の源泉だ。 これは疑いようがない。
ここまで書いてきて、ふと気づいたことがある。私は今、類推について説明するために、言葉を使っている。では、言葉を使うとは、どういうことだろうか。
目の前に、一冊の本がある。私はそれを見て、「本」と呼ぶ。でも、この「本」という言葉は、どこから来たのか。私がこれまでの人生で見てきた、無数の本。図書館で借りた本、書店で買った本、友人にもらった本。それらに共通する何かを抽出して、「本」というカテゴリを作った。目の前の物体を「本」と呼ぶとき、私はそれを、過去に見てきた本たちと「同じ仲間」だと判断している。
これは、類推ではないか。
「この物体は、私が知っている『本』に似ている。だから、これも『本』だ」。言葉を使うとは、目の前の具体的な現象を、過去に学んだカテゴリに当てはめることだ。当てはめるためには、類似性を見出さなければならない。つまり、言語化そのものが、類推なのだ。
そう考えると、言葉の限界も見えてくる。目の前の本には、固有の特徴がある。紙の質感。インクの匂い。背表紙についた小さな傷。誰かが残した付箋。でも、「本」という言葉は、それらを捉えない。「本」という言葉が指すのは、無数の本に共通する抽象的な特徴だけだ。言葉にした瞬間、具体的な豊かさは零れ落ちる。
だから、現状のすべてを完璧に表す言葉は、存在しない。 どんなに言葉を尽くしても、現実には追いつかない。言葉は常に近似だ。現実の一部を切り取っているだけだ。
新しい経験をしたとき、私たちは「これは何だろう」と考える。既存の語彙の中から、「これに近い」言葉を探す。ぴったりの言葉が見つからなければ、複数の言葉を組み合わせる。それでも足りなければ、比喩を使う。「○○みたいなもの」と。でも、どれだけ工夫しても、言葉は現実を完全には捉えられない。
類推は「AはBに似ている」という認識だ。言語化は「この現象は『X』という言葉に似ている」という認識だ。構造は同じだ。どちらも、目の前のものを、既知のものに当てはめる。そして、当てはめることで、何かを得る代わりに、何かを失う。
私たちは、類推なしには思考できない。言葉なしには思考を伝えられない。でも、類推も言葉も、現実を完全には捉えられない。この記事を書いている今この瞬間も、私は類推と言葉の限界の中にいる。その限界を知りながら、それでも書くしかない。だからこそ、類推の限界を知ることが、これほど重要なのだ。
しかし、だからこそ危険なのだ。
類推はなぜ強力なのか
類推の力を、もう少し詳しく見てみよう。
抽象と具体の往復運動
抽象的な概念は、そのままでは理解しにくい。人間の脳は、具体的なイメージを好む。抽象的な数学の公式より、具体的な例題の方が理解しやすい。抽象的な設計原則より、具体的なコード例の方が頭に入る。
類推は、この抽象と具体を往復する運動だ。
日常の例で説明しよう。カレーを作れる人は、シチューも作れる。なぜか。カレーとシチューは、表面的には違う料理だ。でも、「材料を切る → 炒める → 水を入れて煮る → ルーを溶かす」という構造は同じだ。カレーを作った経験から、この「構造」を抽出できれば、シチューに応用できる。これが抽象化であり、類推だ。
プログラミングでも同じだ。具体的なもの(MySQL)を見て、抽象化(データを永続化するシステム)し、別の具体(PostgreSQL)に適用する。この往復が、類推の本質だ。ここで重要なのは、「抽象化」という能力だ。私の理解では、抽象化とは枝葉を切り捨てて幹を見ることだ。個別の事象から、本質的な構造だけを取り出す。MySQL、PostgreSQL、SQLiteはいずれも「SQLでデータを操作するシステム」という抽象に還元できる。Actix-web、Axum、Rocketはいずれも「HTTPリクエストを処理するRustのWebフレームワーク」という抽象に還元できる。
この抽象化ができなければ、類推はできない。類推とは、2つの具体的な事象の間に共通の構造を見出すことだ。共通の構造を見出すには、まず具体から構造を抽出しなければならない。それが抽象化だ。私がこれまで見てきた限り、類推がうまい人は例外なく抽象化がうまい。 正しく抽象化できなければ、正しく類推できない。
面白いことに、抽象の世界が見えている人には具体の世界も見える。でも、具体しか見えない人には抽象の世界が見えない。私はこれをマジックミラーのようなものだと思っている。抽象側からは両方見えるが、具体側からは向こう側が見えない。抽象を理解している人は、具体がその抽象の一例であることがわかる。「あ、これは○○の具体例だな」と。一方、具体しか見えない人は、それが何かの一例だとは気づかない。ただ、個別の事象として見るだけだ。だから、別の具体との共通点が見えない。
多くの人は、この具体と抽象の往復運動を意識したことすらない。私自身、エンジニアになって何年も経ってから、やっと意識できるようになった。それまでは、類推を「なんとなく」やっていた。うまくいくこともあれば、失敗することもあった。でも、なぜ失敗するのかがわからなかった。抽象化を意識するようになってから、類推の成功率が上がった。
「依存性の注入(DI)とは何か」。これはプログラムの設計手法の一つで、名前だけ聞くと難しそうに感じる。これを抽象的に説明すると、「オブジェクトが必要とする依存関係を外部から注入することで、結合度を下げてテスタビリティを高める設計パターン」となる。正確だが、初学者には意味不明だ。でも、「コンセントみたいなものだよ」と言えば、少し見えてくる。家電製品は、壁のコンセントに何が繋がっているか知らなくても動く。発電所が火力でも原子力でも太陽光でも、同じコンセントから電気が来る。DIも同じで、クラスは「何か」からデータベース接続を受け取るが、それが本番のMySQLなのかテスト用のモックなのかは知らなくていい。外部から「注入」される。
類推によって、抽象が具体になる。見えなかったものが、見えるようになる。
未知への橋渡し
人間は、完全に未知のものを理解できない。
新しい概念を学ぶとき、われわれは常に既知のものと関連づける。「これは、あれに似ている」。この関連づけがなければ、新しい知識は宙に浮いてしまう。既存の知識ネットワークに接続できない。類推は、未知と既知をつなぐ橋だ。
Kubernetesを初めて学ぶとする。Kubernetesとは、たくさんのアプリケーションを複数のサーバーで効率よく動かすための管理システムだ。まったく新しい概念だ。でも、「Kubernetesは、コンテナのオーケストラ指揮者みたいなものだ。各コンテナ(アプリケーションを動かす小さな箱)がどこで動くべきか、いくつ動かすべきか、死んだら再起動すべきかを指示する」という類推があれば、入り口が見える。もちろん、この類推は不完全だ。Kubernetesの本質——宣言的な状態管理、コントロールループ、リコンシリエーション——を完全には捉えていない。でも、入り口にはなる。そこから、より正確な理解に進むことができる。
類推は、足場だ。 建設現場の足場のように、本体を作るための仮の構造物だ。足場がなければ、高い建物は建てられない。類推がなければ、深い理解には到達できない。
遠くから借りてくる力
類推は、新しいアイデアを生む。異なる領域を結びつけることで、どちらの領域にも存在しなかった新しい視点が生まれる。
ここで重要なのは、「どこから借りてくるか」だ。興味深いのは、同じ業界から持ってくるとパクりと言われるのに、違う業界からなら革命になることだ。なぜか。同じ業界の人は、同じものを見ている。だから、借りてきたことがすぐにバレる。でも、違う業界から借りてくると、誰も気づかない。そもそも、その業界を知らないからだ。他人が気づかないような遠くから借りてくる。そのために必要なのが、抽象化の力だ。遠い領域同士をつなげるには、それぞれの領域から本質的な構造を抽出しなければならない。表面的な違いを超えて、構造の類似を見抜く。これができる人だけが、革命を起こせる。
生物の進化から、遺伝的アルゴリズムが生まれた。「自然選択と突然変異のプロセスを、最適化問題に適用したらどうだろう」。この類推が、新しい計算手法を生んだ。神経細胞のネットワークから、ニューラルネットワークが生まれた。「脳の情報処理を、コンピュータで模倣したらどうだろう」。この類推が、現在のAI革命の基盤を作った。
私は、類推を創造の触媒だと思っている。異なる領域の知識を化学反応させて、新しいものを生む。遠くから借りてくるほど、その化学反応は激しくなる。近い領域から借りてくると、小さな改善にしかならない。遠い領域から借りてくると、パラダイムシフトが起きる。
コミュニケーションの潤滑油
類推は、相手にとって未知の概念を、既知の概念で説明することを可能にする。
エンジニア同士でも、専門領域が違えば類推は有効だ。フロントエンドエンジニアにバックエンドの認証を説明するとき、JWT(JSON Web Token、ユーザーの認証情報を暗号化して持ち運ぶ仕組み)の説明をする機会がある。「JWTは、入場チケットみたいなものだよ」と言えば伝わる。一度発行されたら、チケット自体に情報が書いてある。だから毎回本部に問い合わせなくても、チケットを見せるだけで入れる。データベースのインデックス(データを高速に検索するための目次)を説明するときも同じだ。「本の索引みたいなものだよ。全ページをめくらなくても、索引を見れば目的の単語がどこにあるかすぐわかる」。
チーム内でも類推は重要だ。リファクタリングとは、プログラムの動作を変えずに、コードの構造を整理・改善することだ。「このリファクタリングは、引っ越しみたいなものだ。荷物を新しい場所に移して、古い場所を片付ける。移行期間中は、両方にアクセスできるようにしておく」。こう言えば、作業のイメージが共有できる。
類推は、異なる背景を持つ人々の間で、共通の理解を作る。
類推はなぜ危険なのか
ここまで読むと、類推は素晴らしいものに思える。実際、素晴らしいのだ。
でも、同時に危険でもある。
なぜか。類推は「AとBは似ている」という前提に立っている。でも、この前提が正しいとは限らない。 似ているように見えて、実は違う。その違いが、致命的な判断ミスを生む。
これは、ベストプラクティスが常に機能しないのと同じ構造だ。カンファレンスやブログで見たあの手法、あの技術、あの設計。「あの会社でうまくいったから、うちでもうまくいくはずだ」。こう考える。でも、これは類推だ。あの会社の文脈と、あなたの文脈は違う。あのチームと、あなたのチームは違う。ベストプラクティスが「ベスト」なのは、特定の文脈においてだけだ。 文脈が変われば、ベストではなくなる。
デザインパターン(プログラム設計でよく使われる定番の解決策のカタログ)も同じだ。「このケースにはあのパターンが使える」と考える。でも、そのパターンが生まれた文脈と、今の文脈は違う。パターンを適用すれば解決するわけではない。パターンは出発点であって、答えではない。
私が「何回説明しても伝わらない」と感じるとき、原因の多くは類推にある。類推は理解のショートカットとして強力だ。でも、相手と自分の「当たり前」が違うと、誤解を生む。なぜなら、類推は相手の頭の中にある既存の枠組みに接続するからだ。その枠組みが私と違えば、同じ言葉でも違う意味になる。
冒頭の所有権の話を思い出してほしい。私は所有権を「本の貸し借りみたいなもの」と理解した。でも、「貸し借り」という言葉には、私が意識していなかった意味も含まれていた。「貸した相手との関係が続く」という意味だ。私は無意識にその意味も読み取っていた。だから、所有権を渡した後も「貸した先」を追跡できると思い込んでいた。類推が、私の思考を歪めていた。
表面的類似と構造的類似の混同
では、なぜ類推は失敗するのか。多くの場合、表面的な類似と構造的な類似を混同しているからだ。
表面的な類似とは、見た目や印象の類似だ。「両方とも丸い」「両方とも赤い」「両方とも動く」。これは、誰でもすぐに気づく。構造的な類似とは、関係性のパターンの類似だ。「Aの中でXとYがこういう関係にあるのと同じように、Bの中でPとQもこういう関係にある」。これは、注意深く見ないと気づかない。
類推が成立するためには、構造的な類似が必要だ。表面的な類似だけでは足りない。 問題は、人間が表面的な類似に騙されやすいことだ。見た目が似ていると、構造も似ていると思い込んでしまう。
あるチームの話を聞いた。少し用語を説明しておこう。「モノリス」とは、1つの大きなプログラムとして構築されたシステムだ。「マイクロサービス」とは、機能ごとに小さなプログラムに分割し、それらを連携させるアーキテクチャだ。大企業が採用して成功したことで有名になった。
そのチームは「マイクロサービスが成功しているから」という理由で、モノリスをマイクロサービスに分割しようとした。「あの有名企業がうまくいったんだから、うちもうまくいくはずだ」。表面的には似ている。「複雑なシステムを小さなサービスに分割する」という点で。しかし、構造は根本的に異なる。その有名企業には数千人のエンジニアがいる。専門のプラットフォームチームがいる。成熟した監視基盤がある。一方、そのチームは10人だった。運用の負荷が爆発的に増え、サービス間の通信障害のデバッグに追われ、結局モノリスに戻すことになった。彼らは、表面的な類似に騙されて、1年を失った。
類推が思考を固定する
類推には、もう1つ危険がある。思考を固定してしまうことだ。
類推は、新しい視点を与える。「これはAみたいなものだ」と気づくと、Aの知識が使えるようになる。これは便利だ。でも同時に、Aの枠組みで考えるようになる。Aの論理で判断するようになる。Aで成立したことは、ここでも成立すると期待するようになる。ここに罠がある。BはAではない。Aにはない特性が、Bにはある。Bにはない特性が、Aにはある。類推によってAの枠組みを持ち込むと、Bの固有性が見えなくなる。Aとの共通点ばかりに目が行き、Aとの違いを見落とす。
私はかつて、新しいチームのマネジメントで失敗した。前のチームで成功した方法を、そのまま適用しようとした。「前のチームと同じようにやればいい」と類推した。でも、チームが違えば、人が違う。カルチャーが違う。技術スタックが違う。ビジネスの文脈が違う。前のチームでうまくいった方法が、新しいチームでは逆効果だった。類推によって、私は新しいチームの固有性を見落としていた。「前のチームみたい」という枠組みが、目の前のチームを正確に見ることを妨げていた。
これは、私だけの話ではない。世の中の「二番煎じ」は、すべてこの構造だ。表面的な成功パターンを真似る。でも、本質的な差異を見落としている。だから、同じ結果が得られない。独自性がないのではない。観察が浅いだけだ。類推が、観察を浅くしている。 成功事例を見て「うちも同じようにやろう」と考えるとき、私たちは無意識に類推している。でも、その類推が正しいかどうかを検証していない。表面的な類似に飛びついて、構造的な違いを無視している。
類推は状況証拠であって物的証拠ではない
ここまでの話をまとめると、こうなる。類推は仮説であって、証明ではない。
類推は、2つの領域の間に構造的な類似があるという仮定に基づいている。「AとBは似ているから、Aで成り立つことはBでも成り立つだろう」。これが類推の論理だ。でも、この仮定は、常に正しいとは限らない。似ているように見えて、実は違う。類推は状況証拠レベルであって、物的証拠レベルには至らない。
ある領域で成功した法則が、別の領域でも通用する保証は、どこにもない。成功事例は、その文脈での成功を証明するだけだ。別の文脈での成功は、証明されていない。
カンファレンスやブログで聞いた、あの会社の組織文化。あの会社でうまくいったからといって、すべての会社で同じ文化がうまくいくわけではない。あの有名な開発手法が成功したからといって、すべてのチームで同じ手法が成功するわけではない。成功事例から学ぶことは重要だ。でも、「あの会社みたいにやればいい」と単純に類推することは、危険だ。あの会社には、あの会社の文脈がある。業界。競合。人材市場。創業者の思想。歴史。規模。成長フェーズ。これらすべてが、あの文化を成立させている。あなたの会社には、あなたの会社の文脈がある。同じ文化を移植しても、機能するとは限らない。むしろ、害になることもある。
より危険なのは、まったく新しい概念や技術を既存のものに無理やり当てはめることだ。ブロックチェーン(暗号技術を使って取引記録を改ざん困難な形で保存する技術)を「分散データベースみたいなもの」と類推すると、その本質的な違いを見落とす。信頼モデル(誰を信頼するか)、コンセンサスメカニズム(参加者間でどうやって合意を取るか)、イミュータビリティ(一度記録したら変更できないこと)——これらの特徴が、通常のデータベースとは根本的に異なる。結果的に間違った理解や過小評価につながる。
類推を絶対視してはいけない。類推は仮説であって、証明ではない。
類推がもたらす知的興奮
ここまで、類推の危険性について書いてきた。でも、誤解しないでほしい。類推は危険だからといって、避けるべきものではない。類推には、代えがたい価値がある。
類推は楽しい。類推は気持ちいい。 私は、類推が成功した瞬間の快感を、何度も味わってきた。
まったく別のことに当てはまった時、頭の中で何かがつながる。あの瞬間——「あ、これって、あれと同じ構造だ」と気づく瞬間——には、独特の快感がある。世界の見え方がガラリと変わる。さっきまでバラバラだったものが、1つの構造で説明できるようになる。混沌が秩序になる。複雑が単純になる。
なぜ、これが気持ちいいのか。
人間は、わからないことに不安を感じる。新しい状況。未知の概念。複雑な問題。これらは、ストレスだ。脳は「これは何だ?」「どうすればいい?」と警戒モードに入る。
でも、類推によって「あ、これは前に見たあれと同じだ」と気づくと、状況が一変する。未知が既知になる。複雑が単純になる。警戒モードが解除される。その瞬間、安堵とともに、快感が走る。
これは、たぶん生存本能と関係している。予測できないものは危険だ。草むらで何かが動いた。あれは風か、それとも獲物か、それとも敵か。わからないと、逃げるべきか近づくべきか判断できない。でも、「あれは風だ」とわかれば、安心できる。予測できるものは安全だ。
類推によって「これは、あれと同じだ」とわかると、予測ができるようになる。「あれ」のときはこうなった。だから、「これ」もそうなるだろう。予測ができると、安心する。安心は快感だ。
しかも、類推は「遠くから借りてくる」ほど快感が大きい。 近い領域の類推——「MySQLはPostgreSQLに似ている」——は、驚きが少ない。当たり前だからだ。でも、遠い領域の類推——「ソフトウェアのリファクタリングは、文章の推敲と同じ構造だ」——は、発見の喜びが大きい。予想外のつながりだからだ。予想外であるほど、「わかった」瞬間のギャップが大きい。だから、快感も大きい。
私がコードを書いていて、まったく関係ないはずの日常の出来事が当てはまることに気づいたとき。障害対応をしていて、これは以前経験した別の問題と同じ構造だと気づいたとき。設計を考えていて、過去に読んだ本の概念が使えると気づいたとき。そのたびに、ゾクっとする。「まさか、ここがつながるとは」という驚き。でも、よく考えると「なるほど、確かに同じだ」と納得できる。この驚きと納得の組み合わせが、最高に気持ちいい。類推の快感は、謎解きの快感に似ている。 バラバラだったピースが、カチッとはまる。見えなかった絵が、見えるようになる。あの瞬間の快感を知っている人は、類推をやめられない。
日本では、この類推の喜びは昔から庶民の間で楽しまれていた。「○○と掛けて□□と解く。その心は△△である」という謎かけだ。まったく関係なさそうな2つのものが、ある抽象的な構造で結びつく。その発見の喜びが、笑いになる。
漫才のツッコミも、類推と関係がある。
ボケは、ある種の「間違った類推」だ。常識から逸脱したことを言う。ツッコミは、その逸脱を指摘する。「いや、それは違うやろ」と。
ツッコミが面白いのは、観客が「そうそう、それはおかしいよね」と共感できるからだ。観客の頭の中にある「普通はこうだ」という枠組み——フレームと呼ぼう——に沿って、逸脱を指摘する。だから笑いが起きる。
これは、類推の逆操作だ。類推が「AはBみたいなものだ」と結びつけるのに対して、ツッコミは「AはBではない」と切り離す。類推の破綻を、観客のフレームに沿って指摘する。
ここで重要なのは、ツッコミが機能するためには、観客のフレームを理解していなければならないということだ。観客が「それはおかしい」と感じるポイントを、正確に捉えなければならない。
これは、類推を使うすべての場面に通じる。
私が所有権を「本の貸し借りみたいなもの」と理解したとき、私は「貸し借り」というフレームの中で考えていた。そのフレームの中では、貸し借りには「誰に貸したか」という追跡可能な関係が含まれていた。私は、そのフレームが当たり前だと思っていた。フレームの存在自体を意識していなかった。だから、フレームの限界が見えなかった。自分自身に対する「ツッコミ」——「いや、Rustの借用は、現実の貸し借りとは違うやろ」——ができなかった。
自分がどんなフレームで類推しているかを意識しなければ、類推の限界が見えない。
良い学習者は、類推を使うと同時に、自分自身でツッコミを入れる。「本の貸し借りみたいなものだけど、貸し借りと違って……」と。このツッコミができるかどうかが、類推で成功する人と失敗する人を分ける。
創造性は異領域からの借用
ソフトウェアエンジニアリングの歴史は、異なる領域からの借用の歴史でもある。
Gitの分散型バージョン管理は、中央集権的なSVNの限界を、分散システムの発想で打破した。Git とは、プログラムの変更履歴を記録・管理するツールだ。SVNは「中央のサーバーにすべてを保存する」方式だったが、Gitは「全員が完全な履歴を持つ」方式を採用した。「すべてのリポジトリが対等なピアである」という考え方は、P2Pネットワークの構造と同じだ。Dockerのコンテナ技術は、仮想マシンの重さを、プロセス分離の軽さで置き換えた。Dockerとは、アプリケーションを「コンテナ」という小さな箱に詰めて、どこでも同じように動かせるツールだ。「OSレベルの仮想化ではなく、プロセスレベルの分離で十分ではないか」という発想が、コンテナ革命を起こした。MapReduceは、分散処理の複雑さを、関数型プログラミングの抽象で単純化した。これは大量のデータを複数のコンピュータで並列処理するための手法だ。「mapとreduceという2つの操作に分解すれば、並列処理が簡単になる」。この類推が、ビッグデータ処理の基盤を作った。
類推は、新しい価値を生むための道具だ。既存の枠組みを超えるための、ジャンプ台だ。
だから、類推を完全に否定できない。
エンジニアリングにおける類推の両面性
ここまで、類推の力と危険について見てきた。類推は強力だ。でも、危険でもある。では、エンジニアとして、類推をどう扱うべきか。
答えは、場面によって使い分けることだ。 類推が有効な場面と、危険な場面がある。それを見極めることが重要だ。
類推が有効な場面
まず、類推が有効な場面を整理しよう。
新しい技術を学ぶとき。 前に学んだ技術との類似点を見つけることで、学習が加速する。「Goのgoroutine(ゴルーチン)は、軽量なスレッドみたいなものか」。スレッドとは、プログラムの中で同時に動く処理の単位だ。goroutineはそれをより少ないメモリで実現する。この類推が、入り口になる。
チームメンバーに説明するとき。 相手が知っている概念に置き換えることで、理解を助ける。「このアーキテクチャは、マイクロサービスというより、モジュラーモノリスに近いよ」。
問題を発見するとき。 「これは前にやったあのプロジェクトに似ている」と気づくことで、早期に問題を予測できる。パターン認識だ。
アイデアを発想するとき。 異なる領域の解決策を、目の前の問題に適用してみる。「他の業界ではどうやっているんだろう」。
これらの場面では、類推は強力なツールだ。
類推が危険な場面
一方で、類推が危険な場面もある。共通点は、「判断」が伴う場面だ。
設計判断を下すとき。 「あの有名な会社がこうやっているから」は、判断の根拠にならない。なぜか。あの会社にはあの会社の文脈がある。規模、チーム構成、ビジネス要件、技術的制約——すべてが違う。自分たちの文脈で、自分たちの制約を考慮して、判断しなければならない。
パフォーマンス予測をするとき。 「前のプロジェクトではこのくらいのスループットだったから」は、予測の根拠にならない。ハードウェアが違う。データが違う。負荷パターンが違う。実測なしに類推で判断すると、本番環境で痛い目に遭う。
チーム運営をするとき。 「前のチームではうまくいったから」は、根拠にならない。人が違う。状況が違う。目の前のチームを、目の前のチームとして見なければならない。
ビジネス判断をするとき。 「あの会社がこうやって成功したから」は、根拠にならない。市場が違う。タイミングが違う。リソースが違う。
「マイクロサービスが流行っているから、うちもマイクロサービスにしよう」。これも類推だ。でも、マイクロサービスが成功した会社と、あなたの会社は違う。チームの規模が違う。運用能力が違う。ビジネスの複雑さが違う。流行りのアーキテクチャは、流行っている理由があるが、あなたの問題を解決する保証はない。「TDD(テスト駆動開発:テストを先に書いてから本体コードを書く開発手法)がいいらしいから、TDDでやろう」。これも類推だ。TDDが有効だった文脈と、今の文脈は同じか。チームのスキルは。締め切りは。要件の安定度は。手法は、文脈とセットでしか評価できない。
これらの場面では、類推に頼らず、具体を見なければならない。
具体を見ろ
ここまでの話から、私がたどり着いた結論はシンプルだ。
類推は入り口として使う。でも、入ったら、具体を見る。
どういうことか。「これはAみたいなものだ」と類推したら、まずはその類推で全体像を掴む。ここまでは類推の力だ。でも、判断を下す前に、次の問いを立てる。「Aとは何が違うんだろう」。違いを具体的に列挙する。その違いが、判断にどう影響するかを考える。
つまり、抽象ではなく、具体を見る。パターンではなく、個別を見る。類似ではなく、差異を見る。
これは、類推の否定ではない。類推の限界を知った上で、類推を使うということだ。類推は入り口として使い、判断は具体に基づいて行う。
入り口と判断を、分離する。 これが、私の結論だ。
類推を使い分ける技術
「入り口と判断を分離する」と言った。では、具体的にどうすればいいのか。私が実践していることを、いくつか紹介する。
類推のレベルを意識する
まず、自分がどのレベルで類推しているかを意識することだ。類推には、レベルがある。
表面的な類推:見た目や印象の類似。「両方とも丸い」「両方ともウェブサービスだ」。
機能的な類推:役割や機能の類似。「両方ともユーザー認証する」「両方ともデータを永続化する」。
構造的な類推:関係性のパターンの類似。「Aの中でXとYの関係が、Bの中でPとQの関係と同じだ」。
原理的な類推:根底にある原理の類似。「両方とも、この物理法則に従う」「両方とも、この経済原理が働く」。
レベルが深いほど、類推は有効だ。表面的な類推は危険だ。原理的な類推は強力だ。
類推をするとき、自分がどのレベルで類推しているかを意識する。表面的な類推に気づいたら、警戒する。
反例を積極的に探す
類推が成立しない場面を、積極的に探す。「これはAみたいだ」と思ったら、「Aとは違う点は何か」を列挙する。「この類推が成立しない条件は何か」を考える。「Aでは成立したが、ここでは成立しないことは何か」を洗い出す。
なぜ反例を探すのか。人間は、類推が成立する証拠ばかりを集める傾向がある。心理学では「確証バイアス」と呼ばれる現象だ。自分が信じたいことを裏付ける情報ばかりを無意識に集めてしまう。「似ている」と感じると、似ている点ばかり目につく。違う点は、無意識にスルーしてしまう。だから、意識的に反例を探さなければならない。反例は、自然には目に入ってこない。
反例が見つかったら、類推の適用範囲を限定する。「この側面ではAに似ているが、この側面では違う」と認識する。反例を探すことは、類推を否定することではない。類推を精密にすることだ。どこまで使えて、どこから使えないのか。その境界線を引く作業だ。
類推と実測を組み合わせる
類推は仮説だ。仮説は検証しなければならない。
私は何度も、類推を信じて痛い目を見てきた。「分かった」と思った瞬間が、一番危ない。類推は、分からないことを「分かったつもり」にさせてくれる。その確信が、検証を怠らせる。
大切なのは、分かっていないことに確信を持たないことだ。
類推で「たぶんこうだろう」と思っても、それは仮説でしかない。仮説に確信を持ってはいけない。確信を持った瞬間、検証しなくなる。検証しなければ、間違いに気づけない。
だから、私は自分にこう言い聞かせている。類推したら、試せ。作ってみろ。動かしてみろ。
「前のプロジェクトと同じくらいのパフォーマンスだろう」と類推したら、実測する。「このアーキテクチャパターンがうまくいくだろう」と類推したら、プロトタイプ(動作確認のための試作品)を作る。「このチーム運営方法が有効だろう」と類推したら、小さく試して観察する。
試した結果、類推が外れることがある。むしろ、外れることの方が多い。でも、外れたときこそ、学びがある。なぜ外れたのか。どこが似ていて、どこが違ったのか。その差異を言語化できたとき、理解が一段深まる。
私は、このサイクルを速く回すことを意識している。1回の大きな検証より、10回の小さな検証。外れることを恐れない。外れるたびに、類推が精密になっていく。
類推は「似ている」という感覚に基づいている。でも、感覚は当てにならない。似ていると思っても、実際には違う。逆に、違うと思っても、実際には同じ。感覚を信じすぎると、現実を見誤る。実測は、感覚を現実に引き戻す。「本当にそうなのか?」を確認する。
類推で仮説を立てて、実測で検証する。 類推は仮説生成の道具であって、証明の道具ではない。
複数の類推を比較する
1つの類推に固執しない。複数の類推を試す。「これはAみたいだ」と思ったら、「でも、Bみたいでもあるな」と考える。「Cという見方もできるな」と広げる。
なぜ複数の類推を試すのか。最初に思いついた類推が、最適とは限らない。むしろ、最初の類推は表面的なことが多い。パッと見て似ているから、思いつく。でも、もう少し考えると、別の類推の方が本質を捉えていることがある。1つの類推に決め打ちすると、その視点でしか見えなくなる。複数の類推を並べると、それぞれの限界が見えてくる。
そして、どの類推が最も適切かを吟味する。どの類推が、最も多くの側面を説明できるか。どの類推が、最も少ない反例を持つか。どの類推が、最も有用な洞察を与えるか。複数の類推を比較することで、1つの類推に囚われることを防ぐ。
類推を言語化する
類推を曖昧なまま使わない。明示的に言語化する。「これはAみたいだ」と思ったら、何がどうAに似ているのか、具体的に言葉にする。「Aのこの側面と、ここのこの側面が、この点で類似している」と。
なぜ言語化が重要なのか。頭の中にある類推は、たいてい曖昧だ。「なんとなく似ている」という感覚で止まっている。でも、言葉にしようとすると、曖昧さが露呈する。「どこが似ているの?」と聞かれて、答えられない。言語化は、自分の思考を試すテストだ。 言葉にできないなら、実はわかっていない。言葉にできて初めて、本当に理解したと言える。
言語化することで、類推が精密になる。曖昧な類推は、誤解を生む。精密な類推は、理解を深める。そして、言語化した類推を、他者に共有する。「私はこう類推しているが、どうだろうか」と問う。他者の視点で、類推の妥当性を検証する。
類推力を鍛える
ここまで、類推の力と限界について語ってきた。では、類推力を高めるには、どうすればいいのか。私が意識していることを3つ挙げる。
1つ目は、遠い領域から引き出しを増やすことだ。私は、咀嚼しやすいものばかり読まないようにしている。数学や哲学、物語やSFなどの自分の仕事や語ることとは遠い世界のストーリーを読んで、自分の経験と照らし合わせる。実生活では役に立たないように見える抽象的な知識こそ、遠くから借りてくる力になる。なぜ遠い領域が大事なのか。近い領域の知識は、みんなが持っている。だから、そこから類推しても、みんなと同じ結論にしかたどり着かない。遠い領域の知識は、自分だけの武器になる。他の人が思いつかない類推ができる。
2つ目は、常に「これは何かに使えないか」と考えることだ。映画を見ても、歴史を学んでも、スポーツを観戦しても、「これは自分の仕事にどう活かせるか」と考える。「関係ない」と決めつけず、「何か応用できないか」という視点で世界を見る。これを続けていると、頭の中に「類推のアンテナ」が立つ。普段の生活の中で、ふと「あ、これって、あれと同じだ」と気づくようになる。その瞬間が、類推力が育っている証拠だ。
3つ目は、構造を2〜3つに絞って抽象化することだ。私の経験では、特徴や要点を2〜3つ挙げて、同じ構造を持つ事象を探すとうまくいく。1つだと何でも結びつけられてしまう。「両方とも存在する」では、類推にならない。4つ以上だと類推先が近くなりすぎて面白味がない。条件が厳しすぎて、同じ業界の似たようなものしか見つからない。2〜3つが、ちょうどいい。適度に絞られていて、適度に広い。
この訓練を続けると、世界の見え方が変わる。一見無関係に見えるものの中に、共通の構造が見えてくる。私は、この感覚を得てから、仕事がずっと面白くなった。ニュースを読んでも、本を読んでも、人と話しても、「これは何かに使えるだろう」と思う。世界が、類推のネタの宝庫に見えてくる。
類推を断つ勇気
ここまで、類推を使い分ける技術について書いてきた。でも、もっと根本的なことがある。それは、類推を断つ勇気だ。
一度つなげた類推を、必要なら断たなければならない。でも、これが難しい。
なぜ難しいのか。
類推は、理解の構造だ。「これはAみたいなものだ」という認識は、思考の足場になっている。その足場の上に、さらに理解を積み重ねている。足場を外すことは、その上に積み重ねたものも崩れることを意味する。
一度「わかった」と思ったものを、「わからない」に戻すのは、心理的に辛い。人間は「わかった」状態を好む。「わからない」状態は不安だ。だから、間違った類推でも、手放したくない。間違っていると薄々気づいていても、「まあ、だいたい合っているだろう」と自分を納得させてしまう。認めたくない。
また、類推は、コミュニケーションの基盤にもなる。チームで「これはAみたいなもの」と共有されていると、それを覆すことは、混乱を生む。「え、今までの説明は何だったの?」と言われる。自分の言ったことを訂正するのは、恥ずかしい。間違いを認めるのは、プライドが傷つく。だから、間違っているとわかっても、言い出せない。みんなが使っている類推に異を唱えるのは、勇気がいる。
でも、間違った類推に固執し続けることの方が、はるかに有害だ。 間違った類推は、間違った判断を生む。間違った判断は、間違った設計を生む。間違った設計は、技術的負債を生む。技術的負債とは、急いで作った不完全なコードが後から修正コストとして跳ね返ってくることだ。借金のように、放置すればするほど利子が膨らんでいく。技術的負債は、チームを疲弊させる。最初の一歩で間違えると、その後のすべてがズレていく。早く気づいて修正するほど、傷は浅い。
だから、類推が間違っていると気づいたら、勇気を持って断つ。「前にAみたいだと言ったけど、よく見たら違った。Bで考え直そう」と言う。これは、弱さではない。強さだ。現実を直視する強さだ。
おわりに
冒頭の話に戻ろう。
私は、Rustの所有権を「本の貸し借りみたいなもの」と理解した。その類推で入り口は開けた。でも、その類推に縛られて、ライフタイムの本質を見誤った。非同期処理を料理に例えて、リソース競合を甘く見た。トランザクションを銀行振込に例えて、ロールバックの複雑さに気づかなかった。キャッシュを「手元に置く」と理解して、無効化の難しさを軽視した。
私は、何度も同じ失敗を繰り返してきた。
正直に言えば、私は今でも類推を使う。毎日のように使う。「これって、あれみたいだな」と考える癖は、もはや私の一部だ。類推なしに思考することなど、私にはできない。たぶん、誰にもできない。
でも、これらの経験を経て、私は類推の使い方を変えた。
類推は入り口として使う。入ったら、具体を見る。 「本の貸し借りみたいなもの」で入ったら、次に「でも、貸し借りと違って、所有権を渡したら元の変数からは完全にアクセスできなくなる。貸した相手を追跡する仕組みはない」と自分に言い聞かせる。類推と差異を、セットで意識する。そして、類推が成り立たない場面に出会ったら、類推を修正する勇気を持つ。
類推は、人間の知能の基盤だ。われわれは類推なしには思考できない。だから、類推を否定するつもりはない。否定できるはずもない。でも、類推の限界を知らなければならない。類推は万能ではない。類推は常に成立するとは限らない。表面的な類推は、本質的な差異を見落とす。
類推で入って、具体で判断する。
類推は仮説であって、証明ではない。
類推を絶対視せず、反例を探し、実測で検証する。そして、間違った類推は、勇気を持って断つ。これが、エンジニアとしての類推の使い方だ。
「それって、○○みたいなものですよね」。この言葉を使うとき、私は今、一瞬立ち止まる。「本当にそうか?」と自問する。表面的な類似に惑わされていないか。本質的な差異を見落としていないか。
先日、後輩にRustの所有権を説明する機会があった。私は「本の貸し借りみたいなものなんだけど」と言った後、こう続けた。「ただし、本と違って、Rustでは貸した先を追跡する仕組みはない。完全に手放すか、借用するかの二択なんだ」。
あの頃の自分には、この補足ができなかった。
類推は強力だ。だからこそ、慎重に扱わなければならない。
おい、類推するな。
いや、違う。
類推しろ。でも、類推を疑え。類推で入って、具体で確かめろ。そして、間違っていたら、断つ勇気を持て。
それが、類推に救われ、類推に何度も裏切られ、それでも類推を愛する人間からの、静かな呼びかけだ。
このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。







