Open16

変数の11個の役割の考察 from プログラマー脳

ハトすけハトすけ

変数の11個の役割

プログラマー脳という書籍で初めて目にした。

  • 固定値
  • ステッパー
  • フラグ
  • ウォーカー
  • 直近の値の保持者
  • 最も重要な値の保持者
  • 収集者
  • コンテナ
  • フォロワー
  • オーガナイザー
  • テンポラリ

詳しくは書籍を読んでほしい。以下引用はすべてこの書籍から。とりあえず一通り眺めてみて、自分なりに解釈を変えようと思う。

注意: 以下の文章は引用以外は独自解釈が多分に含まれています。

ハトすけハトすけ

固定値

初期化された後値が変化しない変数は「固定値」の役割に分類できます。利用しているプログラミング言語が、変更できない変数を仕様として用意しているなら、それは定数として扱うことができ、そうでないなら、初期化された後、変更されていない変数を利用することになります。固定値となる変数の例としては、円周率などの数学的な定数、ファイルやデータベースから読み込んだデータなどが挙げられます。

TypeScriptで例えるなら次のコードでキャメルケースで表したBUCKET_NAMEとか。

ファイル保存.ts
const BUCKET_NAME = 'super_bucket_name';

async function saveStorge(file: any){
  const fileName = nanoid();
  const filePath = `${BUCKET_NAME}/${fileName}`;
  await saveStorage(filePath, file);
}

ただTypeScriptでは基本的にすべての定数をconstで扱うプラクティスがあるので、単に固定値かどうかを、変数の役割 としてみなすには弱すぎる(あまり意味がない)気がするが、そこはどうなんだろう?

ちなみに、関数の引数変数はTypeScriptだと修正できてしまうが、関数スタイルで実装するならこれは固定値として扱ったほうがよい。eslintにno-param-reassignというものがあるので活用しよう。

https://runebook.dev/ja/docs/eslint/rules/no-param-reassign

ハトすけハトすけ

ステッパー

ループ処理を行う際に、ループのたび値が変更(用意された値のリストをステップ)されていく変数があります。これを「ステッパー」と呼びます。ステッパーがとる値は、ループが開始されるタイミングで予測することができます。ステッパーは、forループで利用されるときに標準的に使われるiのような変数の場合もありますが、二分探索を行う際の「size=size/2」のように、繰り返しごとに探索する配列のサイズを半分にするといった、複雑なステッパーが利用される場合もあります。

ということでchatgptに二分探索を書かせてみた。上記引用文だと、midsizeに当たるはずだが、下の書き方だとmidはconstを使っているので固定値になる。となるとleftrightがステッパー変数??? なんか混乱してきた。

binarySearch.ts

/**
 * 二分探索アルゴリズム
 * 
 * @param arr - 昇順にソートされた数値の配列
 * @param target - 探索したい数値
 * @returns targetが見つかった場合、そのインデックスを返す。見つからなかった場合はnullを返す。
 */
function binarySearch(arr: number[], target: number): number | null {
  let left = 0;
  let right = arr.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (arr[mid] === target) {
      return mid;  // ターゲットが見つかった場合、そのインデックスを返す
    } else if (arr[mid] < target) {
      left = mid + 1;  // ターゲットが真ん中より右にある
    } else {
      right = mid - 1;  // ターゲットが真ん中より左にある
    }
  }

  return null;  // 見つからなかった場合、nullを返す
}

せっかくなんでforループを使ってもらった。これならmidをステッパー変数と呼べそう。leftrightは後述する直近の値の保持者なのだろうか?

binarySearch.ts
/**
 * 二分探索アルゴリズム(forループでmidをステップ変数として使用)
 * 
 * @param arr - 昇順にソートされた数値の配列
 * @param target - 探索したい数値
 * @returns targetが見つかった場合、そのインデックスを返す。見つからなかった場合はnullを返す。
 * 
 * @throws Error - 配列が昇順にソートされていない場合
 */
function binarySearch(arr: number[], target: number): number | null {
  let left = 0;
  let right = arr.length - 1;

  // forループでmidをステップ変数として使用
  for (let mid = Math.floor((left + right) / 2); left <= right; mid = Math.floor((left + right) / 2)) {
    if (arr[mid] === target) {
      return mid;  // ターゲットが見つかった場合、そのインデックスを返す
    } else if (arr[mid] < target) {
      left = mid + 1;  // ターゲットが中央より右にある場合、leftを更新
    } else {
      right = mid - 1;  // ターゲットが中央より左にある場合、rightを更新
    }
  }

  return null;  // 見つからなかった場合、nullを返す
}
ハトすけハトすけ

フラグ

何かが発生したことを示したり、何かの情報が含まれていることなどを示す変数です。is_setやis_available、is_errorなどの名前がよく利用されます。フラグは真偽値であることが一般的ですが、整数値や文字列が使われる場合もあります。

これはわかりやすい。個人的にはフラグは次の3種類のユースケースで利用される。

  1. (オブジェクトの1属性として)状態を示す
  2. (関数の引数として)アルゴリズムを変更する
  3. (ループ終了条件を説明する一時変数として)ループを脱出する

あるオブジェクトに含まれれば状態だし、関数の引数に渡されればアルゴリズムを変更するのに利用できる。アルゴリズムの変更に近いが、ループにおいて終了条件を直接if文に書かずに一度説明用に変数に代入するときも、これはフラグだ。

また、フラグはtrue/falseで表せる真偽値だけではない(果たしてフラグと呼ぶかわからないが)、つぎの2パターンも存在する

  • その情報を二値以上で表現する必要がある場合enumやステータスで表現
  • その情報に時間を持たせたい場合タイムスタンプで表現

次のサンプルコードはisLoggedInがフラグになっている。isLoggedInはインスタンスの状態を示しているが、この変数の値によって画面の表示を変えたりするので、同時にアルゴリズムを変更する変数としての役割も担っている。

UserSession.ts
class UserSession {
  private isLoggedIn: boolean = false;

  // ユーザーがログインする関数
  login(): void {
    this.isLoggedIn = true;
    console.log("User logged in.");
  }

  // ユーザーがログアウトする関数
  logout(): void {
    this.isLoggedIn = false;
    console.log("User logged out.");
  }

  // ログイン状態に応じてメッセージを表示する関数
  showDashboard(): void {
    if (this.isLoggedIn) {
      console.log("Welcome to your dashboard!");
    } else {
      console.log("Please log in to access the dashboard.");
    }
  }
}

ハトすけハトすけ

ウォーカー

ウォーカーは、ステッパーと同様にデータ構造を操作するために利用されますが、データ構造を操作する方法が異なっています。ステッパーは常に、あらかじめわかっている値の一覧に対して反復処理を行います。例えば、「for i in range(0, n)」で表されるPythonのforループの変数iなどが、それに当たります。それに対して、「ウォーカー」は、ループ処理を開始する前には、どのように操作を行うのかが未知のケースで利用されます。プログラミング言語の仕様によっては、ウォーカーは、ポインタ変数であったり、整数のインデックスであったりします。ウォーカーは二分探索のようにリストを操作することもできますが、スタックや木構造などのデータ構造を操作する場合のほうが一般的です。ウォーカーの例として、リンクリストを走査して新しい要素を追加する位置を探す変数や二分木の検索インデックスなどが挙げられます。

ということでchatgptにウォーカー変数を使うパターンを生成してもらった。

let node = linkedList.head;
while (node !== null) {
  console.log(node.value); // nodeがウォーカー変数
  node = node.next; // リンクリストの次のノードに移動
}

ステッパー変数との違いがよくわからなかったが、chatgptと壁打ちするうちに次のことがわかった。

  • ステッパー変数は、リストのインデックスを固定のステップで一方向に進めていく
  • ウォーカー変数は、データの構造にあわせてアルゴリズムにそって次の値に進む

配列の二分探索がややこしいのは、配列というリストなのに二分木という構造を扱っているから。さらに次のステップの進み方が、配列の長さの半分という固定のステップ(シンプルなアルゴリズム)なため、ステッパー変数にもウォーカー変数にもみえる。

ステッパー変数はforループで実装しやすくウォーカー変数はwhileループで実装しやすい。

まぁ、配列やリストも数あるデータ構造の1つととらえれば、すべてウォーカー変数ともいえる。あまり深く悩まないほうがよさそう。

ハトすけハトすけ

直近の値の保持者

一連の値を順位処理していく際に、もっとも最新の値を保持する変数を「直近の値の保持者」と呼びます。例えば、直近にファイルから読み込んだ行(line = file.readline())や、ステッパーで最後に参照された配列要素のコピー(element = list[i])などが、これに当たります。

これは最もよくわからない。ループの最後に保持しておいて、次のループで使う場合はフォロワーにあたるし、ループ中に保持しておいて、最後にreturnするなら最も重要な値の保持者収集者にあたるはず。

ハトすけハトすけ

最も重要な値の保持者

ある特定の値を探すために、値の一覧に対して反復処理を行うのはごく一般的なことです。そして、目的となる値、あるいはこれまでに見つかった中で最も適切な値を保持する変数を「最も重要な値の保持者」と呼びます。最小値、最大値、あるいはある条件を満たす最初の値を保持する変数などが典型的な例でしょう。

reduce関数におけるaccumのこと。個人的には収集者との違いはないと思っている。

max.ts
const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5];
let max = numbers[0]; // 最初の値を保持 (これが保持者変数)

// 配列のすべての要素を調べる
for (let i = 1; i < numbers.length; i++) {
  if (numbers[i] > max) {  // 直近の最大値と比較
    max = numbers[i];  // 新しい最大値を保持者変数に更新
  }
}

reduce関数を使った場合。

max-reduce.ts
const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5];

const max = numbers.reduce((accum, current) => {
  return current > accum ? current : accum; // 比較して大きい方を保持
}, numbers[0]);

console.log(max); // 結果: 9
ハトすけハトすけ

収集者

データを集めて、1つの変数に集約させるとき、その変数を「収集者(ギャザーラー)」と呼びます。次のように0から始まりループの中で値をまとめていくような変数が、収集者です。

sum = 0
for i in range(list):
    sum += list[i]

こうした値は関数型言語やある種の関数的な側面をもつ言語では、functional_total=sum(list)のように、直接計算することも可能です。

reduce関数におけるaccumのこと。個人的には、最も重要な値の保持者と違いはないと考えている。強いて言うなら、reduce内のaccumを更新するアルゴリズムにおいて、前のループの計算結果を蓄積するロジックがある場合、よりaccumlator(累積値)という名前の特性が強まる気がする。さきほどの最大値を計算するreduceの場合は、累積ロジックではなく、この値を保持するかどうかの採用ロジックだった。

const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5];

const sum = numbers.reduce((accum, current) => {
  return accum + current;  // 現在の値を累積
}, 0);  // 初期値は0

console.log(sum);  // 結果: 36
ハトすけハトすけ

コンテナ

「コンテナ」とは、複数の要素を内包し、追加や削除が可能なデータ構造のことです。コンテナの例としては、リスト、配列、スタック、ツリーなどが挙げられます。

あるデータ構造から、別のデータ構造を変形(transform)・生成(generate)・抽出(filter)したりするときの返り値となる変数。lodashのコレクション関数は基本的にここのロジックを書きやすくするために存在する。こちらの記事の後半を参考にしてみて。

https://zenn.dev/dove/scraps/8ae23b83609ab1

  • map
  • reduce
  • filter
  • groupBy / keyBy
  • mapValues / mapKeys
  • transform

など。

ハトすけハトすけ

フォロワー

アルゴリズムによっては、前の値や次の値を保持して後から参照する必要な場合があります。このような値を保持する役割をもつ変数は「フォロワー」と呼ばれ、常に他の変数とセットで利用されます。フォロワー変数の例としては、リンクリストを走査する際に前の要素を指すポインタや、二分探索における下位インデックスなどが挙げられるでしょう。

二分探索のサンプルコードにおけるleftrightがこれかな。

ハトすけハトすけ

オーガナイザー

処理を進めるために変数を何らかの方法で変換する必要がでてくるのはよくあることです。例えば、言語によっては、文字列を文字配列に変換しないと、文字列内の個々の文字にアクセスできない場合があります。また、あるリストを並べ直して保存したい場合もあるでしょう。こうした際に、「オーガナイザー」を利用します。オーガナイザーは値を並び替えたり、異なる形式で保存するためだけに使われる変数のことです。オーガナイザーは一般的にはテンポラリ変数でもあります。

インライン化できるローカル変数のことね。

ハトすけハトすけ

テンポラリ

テンポラリ変数は、短期間だけで使われる変数で、tempやtという名前をよく利用します。これらの変数は、変数の値を入れ替えたり、メソッドや関数内で何度も使われる計算結果を保持するために使われたりします。

ソートなどでよく使われる。

bubbleSort.ts
function bubbleSort(arr: number[]) {
  // 配列の長さを取得
  let n = arr.length;

  // 外側のループ:配列全体を何度も繰り返し
  for (let i = 0; i < n; i++) {
    // 内側のループ:隣り合った要素を順に比較して、必要なら入れ替える
    for (let j = 0; j < n - 1 - i; j++) {
      // 現在の要素と隣の要素を比較
      if (arr[j] > arr[j + 1]) {
        // 順序が間違っていた場合、要素を入れ替える
        let temp = arr[j];
        arr[j] = arr[j + 1];
        arr[j + 1] = temp;
      }
    }
  }
  // ソートされた配列を返す
  return arr;
}

TypeScriptの場合、ループ内の一時変数であれば配列の分割代入を使えばtempを生やさなくてすむ。

[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
分割代入を使ったバブルソート
bubbleSort.ts
function bubbleSort<T>(arr: T[], compare: (a: T, b: T) => number): T[] {
  const n = arr.length;
  let swapped: boolean;

  do {
    swapped = false;
    for (let i = 0; i < n - 1; i++) {
      if (compare(arr[i], arr[i + 1]) > 0) {
        // 比較関数の結果が正の数なら、要素を入れ替える
        [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
        swapped = true;
      }
    }
  } while (swapped);

  return arr;
}

// 数値の比較関数
const numberCompare = (a: number, b: number) => a - b;

// 数値の配列をソート
const numbers = [64, 34, 25, 12, 22, 11, 90];
console.log(bubbleSort(numbers, numberCompare));  // 結果: [11, 12, 22, 25, 34, 64, 90]

// 文字列の比較関数
const stringCompare = (a: string, b: string) => a.localeCompare(b);

// 文字列の配列をソート
const words = ['apple', 'orange', 'banana', 'grape'];
console.log(bubbleSort(words, stringCompare));  // 結果: ['apple', 'banana', 'grape', 'orange']

ハトすけハトすけ

以上を踏まえて変数の役割の再考

上記の11の役割の提案は重なる部分があったりとあまり実用的でない気がする。それに、そもそも変数の役割そのものとと関数の役割を保持した結果その役割の性質を帯びたものがごちゃまぜになっている。

そこで個人的に変数の役割をつぎの4つに定義しなおす。ここでは定数も変数の1つとして扱う。

  • 初期値(デフォルトバリュー)
  • 説明変数(インライン化できるものをあえて変数に代入している)
  • キャッシュ(一度計算した値や以前のループの値の保持)
  • ウォーカー(リストのループやデータ構造の走査において現在のインデックスを保持する)

完全に関数型であれば、変数が存在しないのでそもそも上記4つがない。ただし、認知的負荷を下げるために、一時的に変数に代入するような糖衣構文をもつ言語もある。関数型スタイルを貫くTypeScriptであればウォーカー以外の3つをよく使う。アルゴリズムにおいてミュータブルに実装したほうがパフォーマンスが良い場合は、ウォーカーも利用する。

判別方法

  • インライン化できる?
    • できる
      • 静的な値? -> 初期値
      • 動的に導出する値? -> 説明変数
      • できるけど再計算のためあまりしたくない -> キャッシュ
    • できない
      • インデックス?
        • はい -> ウォーカー
        • いいえ、ただの以前の値の写しです -> キャッシュ

11の変数の役割の見直し

役割 見直し 代替手段(一般) 代替手段(関数型スタイル)
固定値 TSでは基本的にconstを使うべきであり、constの役割はさらに細分化できる。 初期値、説明変数 同左
ステッパー、ウォーカー ステッパーの概念はウォーカーに吸収できそう ウォーカー 再帰関数、コレクション関数
直近の値の保持者 ほかの概念と被る。 returnしないキャッシュ reduerやそれに準ずるコレクション関数を使えば出てこない
最も重要な値の保持者 ループにしかでてこない。TSでは基本的にreducerを使う。 returnするキャッシュ reduerやそれに準ずるコレクション関数を使えば出てこない
収集者 ループにしか出てこない。TSでは基本的にreducerを使う。 キャッシュ reduerやそれに準ずるコレクション関数を使えば出てこない
コンテナ ts-patternなどのパターンマッチを使ったり、コレクション関数を使えば出てこない。単に変数の値がリストやマップなだけで他の役割と被る。 キャッシュ reduerやそれに準ずるコレクション関数を使えば出てこない
フォロワー TSでは基本的にreducerを使う。 キャッシュ reduerやそれに準ずるコレクション関数を使えば出てこない
オーガナイザー 変数の役割というより関数の役割とキャッシュとローカル変数を組み合わせたもの キャッシュ、ローカル変数 同左
テンポラリ インライン化できるものとできないものをごちゃまぜに扱ってしまっている。インライン化できればローカル変数、できなければキャッシュ キャッシュ、ローカル変数 同左
ハトすけハトすけ

そもそもインライン化できるのになぜ一度変数に代入するのか?

  • 人間の認知的負荷を減らすため
    • 動的導出に名前を付けることでチャンク化する(ローカル変数)
    • 1や3などのプリミティブ値(マジックナンバー?)を意味のあるものにする(初期値)
    • 式の連続を読み続けるのはワーキングメモリを多用するのでしんどい(ローカル変数)
  • 再計算を減らすため(キャッシュ)
ハトすけハトすけ

変数を使いたいと思ったときの思考方法

  • 単にifやswitch-caseの値をブロック外にもって来るためだけにletを使っている
    • ts-patternを利用する
  • リストやマップなどのデータをもとに新しい値を導出したい
    • パフォーマンスが重要
      • 仕方ないのでミュータブルに実装
    • パフォーマンスがそこまで重要でない
      • mapgroupByなどのlodashのコレクション関数を利用
  • インライン化できるけどローカル変数を使いたい
    • わかりやすさのため
      • むしろ関数化して関数名にその変数名をつける
      • 一度constに入れたほうがテンポがよくなるならそうする
    • 再利用のため
      • 仕方ないが、あまりスコープを長く持ちすぎないほうがよさそう