はじめに
MDNでプリミティブ型の記事を読んでいたら、「プリミティブ型は値を変更できないイミュータブルで、オブジェクト型は値を変更できるミュータブルである。」といった説明がありました。
コードで表現すると、以下のようになります。
const username = "rabin"; // プリミティブ型(イミュータブル)
const userData = { user: "rabin" }; // オブジェクト型(ミュータブル)
// プリミティブ型は、新しい値への「再代入」となるためエラーになる
username = "ラビン"; // TypeError: Assignment to constant variable.
// オブジェクト型は、参照先を変えずに「中身だけを変更」できるため成功する
userData.user = "ラビン";
console.log(userData.user); // 出力: "ラビン"
疑問
ここで、「constで変数を宣言しているのになんでuserDataは変更できるんだ!」という疑問が湧きました。
これまで、値を変更したくないときはconstを用いて宣言し、これを定数と呼んでいました。そのため、上のコードにエラーが起きなかったときは驚きました。
しかし、コンピューターの基礎知識を少しディグった結果、とあることがわかりました。
それは、「constはメモリアドレスを固定するものであり、値そのものを固定するわけではない」ということです。
意味わからないですよね。てことで今回は、const、イミュータブル、ミュータブルの仕組みについて記事にします。
メモリとは
まずはコンピューターの仕組みを理解してきましょう。
コンピューターのプログラムは、基本的に2つの要素で構成されています。
- データ(プリミティブ型やオブジェクト型などの「材料」)
- 処理(メソッドや関数などの「データをどう動かすかの命令」)
コンピューターがこれらを扱う際、内部のパーツはそれぞれ以下のように役割分担をしています。
- HDD/SSD(ストレージ): データやプログラムを長期的に保存しておく「本棚」
- メモリ(RAM): プログラムを実行する際、本棚から必要なデータを取り出し、すぐに扱えるよう広げておく一時的な「作業机」
- CPU: 作業机(メモリ)の上に広げられたデータに対して、実際の計算や処理を行う「脳」
つまり、私たちがプログラム内で変数を宣言したり、データを操作したりするとき、それはすべてこの「メモリ(作業机)」の上で行われています。
コンピューターが処理をする時の動き
例えば、コンピューターに「1+1は何?」とリクエストすると、「2だよ」というレスポンスが返ってきます。
このとき、内部でどのように処理が行われているか、大まかな流れを図にすると以下のようになります。
これを「CPU」と「メモリ」のやり取りに絞り、さらに具体的にしたものが以下の図です。
コンピュータの仕組みを深く解説することが本題ではないため、図の詳細な理解は不要です。 ここで読者に伝えたい最も重要な事実は、「コンピューターで何かを処理するときは、必ずメモリアドレスを使用している」ということです。
メモリアドレスってなに?
ここからちょっと集中して記事を読んでみましょう。
メモリアドレスとは、コンピューターのメモリ(作業机)上にある「データを保存するために割り当てられた住所」のことです。
先ほどの図で登場した 0x120 や 0x123 といった文字列がメモリアドレスです。コンピューターは、すべてのデータをこの数値で管理しています。
しかし、人間がプログラムを書く際、「0x120に "rabin" を保存して、次に…」と、わかりにくいメモリアドレスで管理をするの大変です。
そこで生み出されたのが変数という仕組みです。
変数(例:username)とは、メモリアドレスに貼り付ける人間用のラベルです。
私たちが変数にデータを代入するコードを書くとき、裏側では「特定のメモリアドレスにデータを配置し、その住所へ変数を連携する」という処理が行われています。
現実世界に例えると、関係性は以下のようになります。
- メモリアドレス: 東京都港区芝公園4-2-8(コンピューターが扱う正確な住所)
- 変数: 東京タワー(人間が覚えやすい呼び名)
- 値: 実際の建物(その住所に存在する実際のデータ)
この「変数はデータそのものではなく、メモリアドレス(住所)を指し示す名札にすぎない」という前提知識を持つことで、いよいよ本題である「イミュータブル」と「ミュータブル」の仕組みが明確に理解できるようになります。
本題の前に余談
テクノロジーはすべて、「人間の面倒な作業を楽にするため」に生まれてきたのだと思っています。
複雑な計算を効率化するために電卓が作られ、人間が難解な機械語(メモリアドレスなど)を直接管理しなくて済むようにプログラミング言語が発明されました。現在ではAIの普及により、自然言語の指示だけでプログラミングができるところまで進化しています。
私たちが普段使っている技術も同様です。例えば最近学習したDockerも、開発者ごとに異なるPCで長いコマンドを一つひとつ打ち込む手間を省き、設定ファイルとわずかなコマンドだけで、誰でも瞬時に同じ環境を構築できるようにした技術です。
今回のテーマである「イミュータブル」も、かつてのプログラマーたちが直面した「面倒なバグ」を解決するために生み出された概念です。今学んでいる技術が生まれた背景や歴史を知ることで、なぜその仕組みが必要なのか、より一層理解が深まるはずです。
イミュータブル(不変)とミュータブル(可変)とは
ここからが本題です。
プログラミングにおけるデータには、大きく分けて「変更できないもの」と「変更できるもの」の2種類が存在します。
- イミュータブル(Immutable):メモリアドレスに保存されているデータを変更できない
- ミュータブル(Mutable):メモリアドレスに保存されているデータを変更できる
先ほどのメモリアドレスの話に当てはめると、さらにわかります。
イミュータブルなデータは、一度特定のメモリアドレスに配置されると、その中身を直接書き換えることが絶対にできません。
値を更新したい場合は、別の新しいメモリアドレスに新しいデータを配置し、変数の名札(矢印)をそちらへ繋ぎ変える処理(再割り当て)が行われます。
一方、ミュータブルなデータは、メモリアドレスを固定したまま、中にあるデータだけを直接書き換えることができます。
なぜ「イミュータブル」という概念が生まれたのか
なぜわざわざ「変更できない」という不便そうな性質を作ったのでしょうか?それは、過去のプログラマーたちが「意図しないデータの書き換え(副作用)」によるバグに苦しめられてきたからです。
昔のプログラミングでは、メモリアドレスを直接指定してデータを上書き(ミューテーション)しながら開発するのが一般的でした。
しかし、システムが複雑になり、1つのデータを複数の機能で共有するようになると、「機能Aがデータを上書きした結果、同じデータを参照していた機能Bが予期せずクラッシュする」という事故が多発しました。
この連鎖的なバグを防ぐための強力な安全装置として、「一度作った値は絶対に上書きさせない。変更したいなら別の場所に新しく作れ」というイミュータブルの原則が言語の仕様として組み込まれるようになったのです。
最初の疑問の答え:なぜconstのオブジェクトは変更できたのか?
ここで、記事の冒頭で抱いた疑問に戻ります。
const userData = { user: "rabin" }; // オブジェクト型(ミュータブル)
userData.user = "ラビン"; // なぜ成功するのか?
答えはシンプルです。 constは「変数の参照先(メモリアドレス)を固定する」機能であり、「値そのものを不変にする」機能ではないからです。
userData にはオブジェクト(ミュータブルなデータ)が入っています。 const を使っているため、userData という変数を別のメモリアドレスに繋ぎ変えること(再代入)はエラーになります。 しかし、名札が指し示している「箱の中身」を直接書き換えることまでは制限されていません。
つまり、メモリアドレスが変わらない限り、ミュータブルなオブジェクトの中身は変更可能なのです。これが、定数であるはずの const で宣言した変数が変更できてしまうカラクリです。
終わり
久々のqiita投稿でした。最近コンピューターの根本を勉強していますが、ますますプログラミングが面白くなっている日々です。