0️⃣

ビット演算でUIを制御したい! 基礎編

2024/09/05に公開

TypeScript/JavaScriptで、ビット演算を使ってUIを制御する実装について書きます!
今回は基礎編として、入力項目とビットが1対1に対応するパターンについてデモコードを掘り下げながら解説します。
ビット演算とか二進数って何に使うの? と疑問に思う方にも知ってもらうことで、実際の現場でビット演算を使った実装がしやすくなればみんなハッピー⭐(多分)。

やりたいこと

解説用に👇のような簡単なデモを用意しました!
実際の動作やコードを確認しながら本文を読んでいただければと思います🙇

後述するデモのスクリーンショット

  • Yes/No で回答する何問かのアンケートを設けたい!
  • ユーザーの回答に応じて固有の応答メッセージを表示したい!
  • 応答メッセージをIF文で出し分けるのは冗長すぎる……
  • 回答を一意の値で扱えば簡単に応答メッセージを引き当てられる
  • デモ: https://codesandbox.io/p/sandbox/fzn6t3

「一意の値」とは

Q1. 今日、良い気分ですか? => Yes
Q2. 最近、新しいことに挑戦しましたか? => No
Q3. 今週末、特別な予定がありますか? => Yes

例えば👆みたいなアンケートの回答を 5 という単一の値で表現する。
組み合わせを示す固有の値、値から回答を復元できる。
それが「一意の値」です!

設問の定義

まずはアンケートの設問を定義しよう!
フォームインプットの要素に必要な属性などをここで決めておきます。
ReactやVueでリストレンダリングするために配列で定義しておくのが良さそうですね。

const questions = [
  {
    name: "Q1",
    legend: "今日、良い気分ですか?",
  },
  {
    name: "Q2",
    legend: "最近、新しいことに挑戦しましたか?",
  },
  {
    name: "Q3",
    legend: "今週末、特別な予定がありますか?",
  },
] as const;

各設問の Yes/No の値は定義しなくて良いの?と思われたかもしれません。
実はビット演算を用いることで、わざわざ定義しなくても機械的に求めることができちゃうのです!
むしろその為にこそビット演算を用いたいわけですね。
その方法については後述します。

アンケートフォームを作る

設問定義の配列をリストレンダリングしてアンケートフォームを作ってみましょう。
例えばReactなら👇な感じ。

// (一部略)
function App() {
    return (
        <div className="App">
            <form>
                {questions.map(({ legend, name }, i) => {
                    return (
                        <fieldset key={`question-${name}`}>
                            <legend>
                                <i>{name}.</i>
                                <span>{legend}</span>
                            </legend>
                            {/* Yesのラジオボタン */}
                            <input id={`question-${name}-yes`} type="radio" name={name} />
                            <label htmlFor={`question-${name}-yes`}>Yes</label>
                            {/* Noのラジオボタン */}
                            <input id={`question-${name}-no`} type="radio" name={name} />
                            <label htmlFor={`question-${name}-no`}>No</label>
                        </fieldset>
                    );
                })}
                <div>
                    <button type="submit">送信</button>&nbsp;
                    <button type="reset">リセット</button>
                </div>
            </form>
        </div>
    );
}

回答内容を格納する

アンケートの回答内容は「一意の値」で扱うのだから 1つの変数に格納できる わけです。
ステートとして定義することで、ユーザーの入力と状態を同期できますね!

// (一部略)
function App() {
    const [state, setState] = useState(0);
    
    return (
        <div className="App">
            // (中略)
        </div>
    );
}

ステートには初期値が必要ですが、👆はすべて No を選択している状態を初期値としています。
なぜ 0 が「すべて No 」にあたるのかについては後述しますね。

応答メッセージの定義と引き当て

応答メッセージも配列で定義しておけば、 回答を示す「一意の値」をインデックスとして用いることができます。
例に挙げたアンケートでは8通りの応答メッセージが必要です。

const answers = [
  /* インデックス 0 */ "今日は少し元気がない……大切にしてください。",
  /* インデックス 1 */ "今日は良い気分なんで……り過ごしてください。",
  /* インデックス 2 */ "今日は少し元気がない……り休んでくださいね。",
  /* インデックス 3 */ "今日は良い気分なんで……て過ごしてください。",
  /* インデックス 4 */ "今日は少し元気がない……戻せると良いですね。",
  /* インデックス 5 */ "今日は良い気分なんで……予定が楽しみですね!", // Hit!
  /* インデックス 6 */ "今日は少し元気がない……のになると思います!",
  /* インデックス 7 */ "今日は良い気分なんで……て、楽しそうですね!",
] as const;

「一意の値」は前項で定義した state に格納するので、例えばその値が 5 であるとき answers[state] は👆を返します。
今回のデモではこれをそのまま <form onSubmit /> で呼び出しちゃいますね。
合わせて <form onReset /> にも state をリセットする処理を記述します。

// (一部略)
function App() {
    return (
        <div className="App">
            <form
                onSubmit={(ev) => {
                    ev.preventDefault();
                    window.alert(answers[state]);
                }}
                onReset={() => setState(0)}
            >
            // (中略)
            </form>
        </div>
    );
}

🤔 5 って何?どうやって一意の値を決めるの?

そこでビット演算です!

そもそもビットとは

  • 2値を取る情報の基本単位、 二進数 である
  • True/False, Yes/No, On/Off などを扱える
  • プログラミングでは一般的に 0/1 を取る
  • 複数桁のビットの羅列を「ビット列」や「ビットパターン」と呼ぶ
  • 桁数を取って 1ビット, 2ビット, 3ビット…nビット と数える
  • nビットは 2^n 通りの組み合わせを表現できる

※参考: ビット - Wikipedia

nビット = 2^n

十進数の表現できる組み合わせ数は、桁が増える度に10倍(=10^{桁数})になっていきます。
ビットは二進数なので、表現できる組み合わせ数は桁が増える度に2倍(=2^{桁数})になるわけです。

桁数 表現できる組み合わせ数 取り得る値(十進数)
十進数 1桁 10^1 = 10通り 0〜9
十進数 4桁 10^4 = 10,000通り 0〜9,999
二進数 1桁 2^1 = 2通り 0〜1
二進数 4桁 2^4 = 16通り 0〜15

例に挙げたアンケートは、Yes/No で回答する設問が3問あります。
すなわち、回答は 2^3 通りの組み合わせがあり、3ビットで表現することができるわけです。

設問ごとに対応したビット列

設問ごとの回答を保存するためには、設問とビット列を1対1に対応させて考える必要があります。
Yes と回答するときに、その設問に対応する桁のビットに 1 を立てて値を保存するのです。
単純により小さい桁から対応させてみましょう。

Q1. 今日、良い気分ですか? / 1桁目で扱う
=> Yesの時、 001
Q2. 最近、新しいことに挑戦しましたか? / 2桁目で扱う
=> Yesの時、 010
Q3. 今週末、特別な予定がありますか? / 3桁目で扱う
=> Yesの時、 100

下位の桁から Yes の時だけ 1 を立てるので、例えば Q1=Yes, Q2=No, Q3=Yes と回答したときのビット列は👇のようになります。

Q1. Yes => 1 ───┐
Q2. No  => 0 ──┐│
Q3. Yes => 1 ─┐││
              101

このビット列、すなわち二進数の 101 を十進数にすると 5 です!
また、全ての設問に No で答えた場合はどの桁のビットも 0000 です。だから state の初期値は十進数の 0 としたってわけですね。

このように、特定の桁のビットに 1 を立てることをフラグを立てるとも言います。
では、設問ごとに Yes の時にフラグを立てるための処理はどうやって実装すれば良いでしょうか?
そこでビット演算の出番です!次項からはいよいよビット演算の解説をしていきますよ。

二進数/二進法って教わった?

みなさん、二進数/二進法って学校で教わりましたか?
どうやら世代によって異なるようで、私のようなおじさんは中学の数学で習ったんですが、今は義務教育課程では扱っていないそうですね…。高校の情報処理では扱うのかな?
また、 1 + 1 = 10 みたいな二進数の計算を教わった方もいらしゃるかと思うんですけど、正直このような計算はプログラミングではあまり用いないんですよね。
もしかしたら学校では習わなかったかも…。でも、プログラミングに触れるにあたって二進数は必要不可欠なので是非とも学ぶ機会を得たいですね!

※参考: 二進法 - Wikipedia

TS/JSで二進数をリテラルに定義する

通常TS/JSでは数値リテラルは十進数で定義します。ほとんどの場面ではそれで特に問題は無いはずですが、例えば必要なビット数が予め分かっている場合などでは最初から二進数で定義する方がコードの可読性が上がるかも?。
そんなときのために(?)、TS/JSでは 0b という接頭辞を用いることで二進数をリテラルに定義することができます。
ただ、二進数リテラルで定義したとしても出力される際には十進数で表現されるのでご注意を🙏

// 例)予め3ビットだけ必要だと分かっている場合に桁を埋めておくとコードが読みやすいかも
const x = 0b000 // 十進数で 0
const y = 0b101 // 十進数で 5

console.log(y) // => 5 十進数で出力される

※参考: 文法とデータ型#数値リテラル - JavaScript | MDN

ビット演算

ビット演算とは、ビットの位置の移動やビット単位の論理演算をする操作を言います。
今回の例で必要なビット演算は以下の通りです。

  • ビットの位置の移動
    • 左シフト: 設問ごとの( Yes の)値を求める
  • ビット単位の論理演算
    • ビット論理積(AND): カレント値を求める
    • ビット論理和(OR): Yes で回答する際に使用する
    • ビット排他的論理和(XOR): No で回答する際に使用する

次節以降ではこれらのビット演算の使い方を説明します!

※参考: ビット演算 - Wikipedia

左シフト

ビット列を指定された桁の分、左にずらします。
ずれた右側は 0 で埋められます。

  000000001 左シフト 1
= 000000010

  000000001 左シフト 2
= 000000100

TS/JSでは << 演算子がその機能を果たします。

const x = 1 // 十進数で定義、十進数の 1 は 二進数でも 1

console.log(x << 1) // => 2
// x は内部的に二進数に変換されて 1 => 1桁左シフトする => 結果は二進数で 10 十進数で 2

console.log(x << 2) // => 4
// x は内部的に二進数に変換されて 1 => 2桁左シフトする => 結果は二進数で 100 十進数で 4

ブラウザの開発者ツールを開いて、コンソールに👆のコードを貼り付けて実際の動作を確認してみよう!
console.log の出力は十進数で、二進数で扱われるのはあくまで内部的にであることに注意!

左シフトで Yes の値を求める

左シフト とリストレンダリングのインデックスを用いることで、設問ごとの Yes の値を求めることができます。

// (一部略)
{questions.map(({ legend, name }, i) => {
    // Yesの値は 1 を i で **左シフト** して求める
    const trueValue = 1 << i;
    return (
        <fieldset key={`question-${name}`}>
            {/* Yesのラジオボタン */}
            <input
                id={`question-${name}-yes`}
                type="radio"
                name={name}
                value={trueValue}
            />
        </fieldset>
    );
})}

👆のコードによって各設問の trueValue は👇のように計算されるわけです。
事前に考えた「設問ごとに対応するビット列」ができていることにお気づきでしょう👍

設問 インデックス trueValue (二進数表記)
Q1. 0 1 << 0 1
Q2. 1 1 << 1 10
Q3. 2 1 << 2 100
「1」 の左シフトは 「2のn乗」 に等しい

左シフトは1桁ずらすごとに元の値を倍にします。なので 1 << n はべき乗演算子を使って 2 ** n と書くこともできます。
表にしてみましょう。

設問 インデックス trueValue (十進数表記 = 二進数表記)
Q1. 0 2 ** 0 1 = 1
Q2. 1 2 ** 1 2 = 10
Q3. 2 2 ** 2 4 = 100

No の値はなんでも良い

Yes の値が設問ごとにユニーク値であるべきなのに対して、 No の値は Yes の値と異なりさえすればなんでも良いです。
今回は 0 としましょうか。

// (一部略)
(
{/* Noのラジオボタン */}
<input id={`question-${name}-no`} type="radio" name={name}
    value={0}
/>
)

ビット論理積(AND)

2つのビット列の、桁ごとの論理積です。
すなわち2つのビットが どちらも 1 ならば 1 を取ります。

    000000101
AND 000000001
-------------
    000000001 // どちらも 1 の桁だけ 1 にする

TS/JSでは では & 演算子で求められます!
比較演算子の && とは異なるよ。

const x = 5 // 十進数で定義、十進数の 5 は 二進数では 101
const y = 1 // 十進数で定義、十進数の 1 は 二進数でも   1

console.log(x & y) // => 1

// x は内部的に二進数変換されて 101
// y は内部的に二進数変換されて   1
// AND でどちらも 1 の桁だけ 1 にする => 結果は二進数で 1 十進数でも 1

AND でカレント値を求める

AND は各桁のフラグが立っているかを調べるために用います。
入力内容が保存されている state と、設問ごとの Yes の値である trueValueAND を取ることで、設問ごとのカレント値、すなわちユーザーが入力した現在の値を求められるのです。
今回の例のラジオボタンは Yes/No で対になっているので、 Yes か否かが分かれば双方の checked 属性を設定できますね。

// (一部略)
{questions.map(({ legend, name }, i) => {
    const trueValue = 1 << i;
    // Yesであるか否かは **AND** を取る
    const checkedTrue = Boolean(state & trueValue);
    return (
        <fieldset key={`question-${name}`}>
            {/* Yesのラジオボタン */}
            <input checked={checkedTrue} />
            {/* Noのラジオボタン */}
            <input checked={!checkedTrue} />
        </fieldset>
    );
})}

前項で 左シフト して求めた各設問の trueValue を改めて一覧します。

設問 trueValue (十進数表記) trueValue (二進数表記)
Q1. 1 1
Q2. 2 10
Q3. 4 100

現在の state5 としたとき、設問ごとの trueValueAND を取る式は👇のようになります。

設問 式 (十進数表記) 式 (二進数表記)
Q1. 5 & 1 101 AND 1
Q2. 5 & 2 101 AND 10
Q3. 5 & 4 101 AND 100

AND は、桁ごとに どちらも 1 ならば 1 を取るので計算結果は👇の通りになります。

設問 解(十進数表記) 解(二進数表記) checkedTrue
Q1. 1 1 true
Q2. 0 0 false
Q3. 4 100 true

このようにして、設問ごとにフラグが立っているかを調べるのです。

ビット論理和(OR)

2つのビット列の、桁ごとの論理和です。
すなわち2つのビットのうち どちらかが 1 ならば 1 を取ります。

    000000101
OR  000000001
-------------
    000000101 // どちらかが 1 の桁を 1 にする

JavaScriptでは | 演算子で求められます!
比較演算子の || とは異なるよ。

const x = 5 // 十進数で定義、十進数の 5 は 二進数では 101
const y = 1 // 十進数で定義、十進数の 1 は 二進数でも   1

console.log(x | y) // => 5

// x は内部的に二進数変換されて 101
// y は内部的に二進数変換されて   1
// OR でどちらか 1 の桁を 1 にする => 結果は二進数で 101 十進数で 5

OR で回答を Yes にする

OR は指定のフラグを立てるために用います。
入力内容が保存されている state と、設問ごとの Yes の値である trueValueOR を取ることで、とある設問の回答を Yes とすることができるのです。

// (一部略)
{questions.map(({ legend, name }, i) => {
    const trueValue = 1 << i;
    return (
        <fieldset key={`question-${name}`}>
            {/* Yesのラジオボタン */}
            <input
                // Yesにするには **OR** を取る
                onChange={() => setState(state | trueValue)}
            />
        </fieldset>
    );
})}

再度 左シフト して求めた各設問の trueValue 一覧に登場いただきましょう。

設問 trueValue (十進数表記) trueValue (二進数表記)
Q1. 1 1
Q2. 2 10
Q3. 4 100

現在の state5 として「Q2」を Yes とするとき、 state と「Q2」の trueValueOR を取る式と解は👇のようになります。

十進数表記 5 | 2 7
二進数表記 101 OR 10 111

OR は、桁ごとに どちらかが 1 ならば 1 を取るのでしたね。
計算結果をご覧いただくと全ての桁のフラグが立っているのがお分かりいただけると思います。
すでに Yes だった「Q1, 3」はそのままに、加えてもともとは No だった「Q2」を Yes にすることができた、ってことですね!

ビット排他的論理和(XOR)

2つのビット列の、桁ごとの排他的論理和です。
すなわち2つのビットのうちどちらか一方だけが 1 ならば 1 を取ります。

    000000101
XOR 000000001
-------------
    000000100 // どちらか一方だけが 1 の桁を 1 にする

JavaScriptでは ^ 演算子で求められます!
算術記号の ^ とは異なる(ややこしい)。

const x = 5 // 十進数で定義、十進数の 5 は 二進数では 101
const y = 1 // 十進数で定義、十進数の 1 は 二進数でも   1

console.log(x ^ y) // => 4

// x は内部的に二進数変換されて 101
// y は内部的に二進数変換されて   1
// XOR でどちらか一方だけが 1 の桁を 1 にする => 結果は二進数で 100 十進数で 4

XOR で回答を No にする

XOR は指定のフラグを降ろすために用います。
入力内容が保存されている state と、設問ごとの Yes の値である trueValueXOR を取ることで、とある設問の回答を No とすることができるのです。

// (一部略)
{questions.map(({ legend, name }, i) => {
    const trueValue = 1 << i;
    return (
        <fieldset key={`question-${name}`}>
            {/* Noのラジオボタン */}
            <input
                // Noにするには **XOR** を取る
                onChange={() => setState(state ^ trueValue)}
            />
        </fieldset>
    );
})}

またまた 左シフト して求めた各設問の trueValue 一覧の登場です。
しつこかったらごめんなさい🙇

設問 trueValue (十進数表記) trueValue (二進数表記)
Q1. 1 1
Q2. 2 10
Q3. 4 100

現在の state5 として「Q1」を No とするとき、 state と「Q1」の trueValueXOR を取る式と解は👇のようになります。

十進数表記 5 ^ 1 4
二進数表記 101 XOR 1 100

XOR は、桁ごとに どちらか一方だけが 1 ならば 1 を取るのでした。
つまり、すでに立っているフラグを降ろすことができるというわけです。

応答メッセージとのマッピング

さて、応答メッセージを定義した際に、answers[state] で表示するメッセージを引き当てることにしました。
state には回答の組み合わせを示したビット列(を十進数にした値)が格納されるので、応答メッセージの配列は対応するメッセージのインデックスがビット列に等しくなるように定義する必要があります。

const answers = [
  /* インデックス 0, ビット列 000  */ "今日は少し元気がない……大切にしてください。",
  /* インデックス 1, ビット列 001  */ "今日は良い気分なんで……り過ごしてください。",
  /* インデックス 2, ビット列 010  */ "今日は少し元気がない……り休んでくださいね。",
  /* インデックス 3, ビット列 011  */ "今日は良い気分なんで……て過ごしてください。",
  /* インデックス 4, ビット列 100  */ "今日は少し元気がない……戻せると良いですね。",
  /* インデックス 5, ビット列 101  */ "今日は良い気分なんで……予定が楽しみですね!",
  /* インデックス 6, ビット列 110  */ "今日は少し元気がない……のになると思います!",
  /* インデックス 7, ビット列 111  */ "今日は良い気分なんで……て、楽しそうですね!",
] as const;

今回のデモのように応答をハードコードするなら、👆のようにビット列をコメントで書いておくのもアリかもです。

安全なのは31ビットまで

これまでの本文中では最小限の桁数で二進数を表記しています。
しかし実を言うと、TS/JSではビット演算を行う際に数値を 符号付きの32ビット整数 に変換するのです。
「符号付き」とは、最上位の桁を 数の正負を決めるために使う 数値のことを言います。最上位の桁が 0 ならば正の数、 1 ならば負の数として扱います。
「32ビット整数」は32桁の二進数なので、最上位の32桁目が 1 になる場合に負の数とされるわけです。
これは、ビット演算をUIに用いるなら31ビットに収まる範囲で使用すべきであることを示しています。

const x = 0b10000000000000000000000000000001 // 32ビットの二進数を定義、十進数で 2147483649 (定義時点では符号 **無し** なので「正の数」)
const y = 0b00000000000000000000000000000001 // 32ビットの二進数を定義、十進数で 1

console.log(x | y) // => -2147483647
//     10000000000000000000000000000001
// OR  00000000000000000000000000000001
// ------------------------------------
//     10000000000000000000000000000001
//     👆見た目では元の x と同じに見えるが
//     **符号付きの32ビット整数** に変換されているので結果は「負の数」となる

結果が「負の数」になり得るということは、例えば今回のデモのように answers 配列で応答を用意した場合にビットとインデックスのマッピングが取れない(配列のインデックスを「負の数」にはできない(厳密にはできなかないけど))ということになるわけです。
よって、ビット演算でUIを制御するならば安全なのは31ビットまで、と言えるでしょう。

なお、31ビットで表現できる組み合わせは 21億4,748万3,648通りにも及ぶので、そもそもそんなに大量の組み合わせをUIでハンドリングすることはまず無いと思われます…😅

※参考: とほほの数値フォーマット入門#32ビット整数(符号付き) - とほほのWWW入門

まとめ

長くなりましたがまとめです!

  • ユーザー入力やデータとUIとのパターンマッチングをしたいとき、ビット演算が使える!
  • ビット演算を使うと複数の選択を一意の値で扱える
  • 2値を取り得る項目は1項目につき1ビット必要
  • <<, &, |, ^ 覚えておくと便利
  • ただし安全なのは31ビットまで

今回解説した実装からビット演算をロジックに使っている箇所をコンポーネントとして切り出すと再利用が可能です。
例えば設問定義の配列をPropとして受け取るようにして、コンポーネント側ではテンプレートの出力とイベントのハンドリングを受け持ってもらうのです。
そうすれば、設問や応答メッセージはAPIなどでサーバーから受け取るという仕組みすることもできますね!

複数のフラグの組み合わせによってUIを出し分けたい場面でテンプレートがIF文だらけ… なんてコードありますよね?そんな場面でパターンを数値化できるビット演算を使えばテンプレートをシンプルに書くことができるのです!
意外にもビット演算ってUIの制御と相性が良いんです。実際ゲームなどでは古くから使われていて、そのために「フラグ立った」なんてネットスラングが浸透したのですね〜。
是非これを機会に使ってみてくださいね😉

次回、応用編

回答の選択肢が3つ以上ある設問を例示している

選択肢が複数…?!
乞う、ご期待!

※画面は開発中のものです

株式会社トゥーアール

Discussion