整数と算術演算

ifおよびwhile文を使用すると、論理式としてbool型を使用して、プログラムで決定を行うことができることをこれまで見てきた。この章では、Dの整数型に対する算術演算について説明する。これらの機能を使用すると、より有用なプログラムを書くことができるようになる。

算術演算は、私たちの日常生活の一部であり、実際には単純なものだが、正しいプログラムを作成するには、プログラマーが知っておくべき非常に重要な概念がある。それは、型のビット長オーバーフロー(ラップ)、および切り捨てである

さらに進む前に、以下の表に算術演算をまとめ、参考としておこう。

演算子効果
++1増分++variable
--1減算--variable
+2つの値を加算した結果first + second
-"first"から"second"を引いた結果first - second
*2つの値を乗算した結果first * second
/"first"を"second"で割った結果first / second
%'first'を'second'で割った余りfirst % second
^^"first"を"second"乗した結果
("first"を"second"回乗した結果)
first ^^ second

これらの演算子のほとんどには、=記号が付いた対応する演算子がある:+=-=*=/=%=、および^^=。これらの演算子の違いは、結果を左辺に代入する点にある:

variable += 10;
D

この式は、variableと10の値を加算し、その結果をvariableに代入する。最終的に、variableの値は10増加する。これは、次の式と同等だ。

variable = variable + 10;
D

以下で詳しく説明する前に、ここで2つの重要な概念をまとめておこう。

オーバーフロー:すべての値が、特定の型の変数に収まるわけではない。値が変数に対して大きすぎる場合、その変数はオーバーフローすると言う。例えば、型ubyteの変数は、0から255までの値しかとることができない。したがって、260が割り当てられると、変数はオーバーフローし、ラップアラウンドして、その値は4になる。(注釈:CやC++などの他の言語とは異なり、Dでは、符号付き型のオーバーフローは合法である。これは、符号なし型と同じラップアラウンド動作をする。

同様に、変数はその型の最小値よりも小さい値を持つことはできない。

切り捨て:整数型は、小数部分を持つ値を持つことはできない。例えば、int3/2の値は1.5ではなく1になる。

私たちは日常的に算術演算に遭遇するが、特に驚くことはない:ベーグルが$1なら、2つのベーグルは$2であり、サンドイッチが$15なら、1つのサンドイッチは$3.75となる。

残念ながら、コンピュータでの算術演算はそれほど単純ではない。コンピュータで値がどのように保存されているかを理解していないと、ある企業が30億の負債を抱えているのに、さらに30億を借りた結果、負債が17億に減少したことに驚いてしまうだろう。また、アイスクリーム1箱で4人の子供たちが食べられるのに、算術演算では11人の子供たちに2箱で十分だと計算されてしまうだろう。

プログラマーは、整数がコンピュータ内でどのように格納されているかを理解しなければならない。

整数型

整数型は、-2、0、10などの整数値しかとることができない型だ。この型は、2.5のように小数部分をとることができない。基本型で見た整数型は、すべて次の通りだ。

ビット数初期値
byte80
ubyte80
short160
ushort160
int320
uint320
long640L
ulong640LU

型名の先頭にある"u"は"符号なし"を意味し、その型は0未満の値をとることができないことを示す。

これらは0と同じだが、0L0LUは、それぞれlongulongという型を持つ明示定数である。

型のビット数

今日のコンピュータシステムでは、情報の最小単位はビットと呼ばれている。物理的なレベルでは、ビットはコンピュータの回路内の特定のポイント周辺の電気信号によって表される。ビットは、そのビットを定義する領域内の電圧の違いに応じて2つの状態のうちの1つになる。この2つの状態は、任意に0と1の値を持つように定義されている。その結果、ビットは2つの値のうちの1つを持つことになる。

2つの状態だけで表現できる概念はそれほど多くないため、ビットはあまり有用な型とは言えない。ビットは、表か裏か、ライトのスイッチがオンかオフかなど、2つの状態を持つ概念にしか使用できない。

2ビットを一緒に考えると、表現できる情報の総量は倍になる。各ビットの値は0または1なので、合計4つの状態がある。左と右の数字がそれぞれ1ビット目と2ビット目を表すと、これらの状態は 00、01、10、11になる。この効果をより明確にするため、もう1ビット追加しよう。3ビットは8つの異なる状態を取ることができる:000、001、010、011、100、101、110、111。ご覧の通り、追加するビットごとに、表現可能な状態の総数は2倍になる。

これらの8つの状態に対応する値は、慣例によって定義されている。次の表は、3ビットの符号付きおよび符号なし表現のこれらの値を示している。

ビット状態符号なし値符号付き値
00000
00111
01022
01133
1004-4
1015-3
1106-2
1117-1

ビットを追加することで、以下の表を作成できる:

ビット異なる値の数Dの型最小値最大値
12
24
38
416
532
664
7128
8256byte-128127
ubyte0255
......
1665536short-3276832767
ushort065535
......
324294967296int-21474836482147483647
uint04294967295
......
6418446744073709551616long-92233720368547758089223372036854775807
ulong018446744073709551615
......

表の多くの行を省略し、同じ行にある同じビット数のDの型の符号付きと符号なしのバージョンを示した(例えば、intuintはどちらも32ビットの行にある)。

型の選択

Dには3ビットの型はない。しかし、このような仮想の型は8つの異なる値しか持つことができない。これは、サイコロの目の値や曜日の番号などの概念しか表現できない。

一方、uintは非常に大きな型だが、その最大値は世界人口70億人よりも少ないため、生きている人それぞれのID番号の概念を表すことはできない。longulongは、多くの概念を表すには十分すぎるほどである。

原則として、特に理由がない限り、整数値にはintを使用。

オーバーフロー

型は限られた範囲の値しか保持できないため、予期しない結果になることがある。例えば、値が30億の2つのuint変数を加算すると、結果は60億になるはずだが、その和はuint変数が保持できる最大値(約40億)を超えるため、この和はオーバーフローする。警告は表示されず、6と40億の差だけが格納される(より正確には、6から43億を引いた値)。

切り捨て

整数は小数部を持つことができないため、小数点以下の部分は失われる。例えば、1箱のアイスクリームが4人の子供たちに分配されるとする。実際には11人の子供たちに2.75箱必要だが、その小数部は整数型では格納できないため、値は2になる。

この章の後半で、オーバーフローと切り捨てのリスクを軽減するための限定的なテクニックを説明する。

.min .max

以下では、基本型で説明した .minおよび.maxプロパティを利用する。これらのプロパティは、整数型が持つことができる最小値と最大値を提供する。

加算:++

この演算子は、単一の変数(より一般的には単一の式)とともに使用され、その変数の名の前に記述される。この演算子は、その変数の値を1ずつ増加させる。

import std.stdio;

void main() {
    int number = 10;
    ++number;
    writeln("New value: ", number);
}
D
arithmetic.1
新しい値11

加算演算子は、値1を使用して加算および代入演算子を使用することと同じである。

number += 1;      // ++numberと同じ
D

加算演算の結果がその型の最大値よりも大きい場合、結果はオーバーフローして最小値になる。この効果は、最初は値int.maxを持つ変数を加算することで確認できる。

import std.stdio;

void main() {
    writeln("minimum int value   : ", int.min);
    writeln("maximum int value   : ", int.max);

    int number = int.max;
    writeln("before the increment: ", number);
    ++number;
    writeln("after the increment : ", number);
}
D
arithmetic.2

加算の後、値はint.minになる。

最小intの値-2147483648
最大intの値2147483647
増分前の値2147483647
増分後の値-2147483648

これは、加算の結果、警告もなしに値が最大値から最小値に変化するため、非常に重要な観察結果である。この効果は、オーバーフローと呼ばれる。他の演算でも同様の効果が見られる。

減算:--

この演算子は、加算演算子と似ているが、値が1だけ減少するという点が違う。

--number;   // 値が1減少する
D

減算演算は、値1を用いた減算と代入の演算と同じだ。

number -= 1;      // --numberと同じ
D

++演算子と同様に、値が最初に最小値の場合、値は最大値になる。この現象もオーバーフローと呼ばれる

加算: +

この演算子は2つの式で使用され、それらの値を足す。

import std.stdio;

void main() {
    int number_1 = 12;
    int number_2 = 100;

    writeln("Result: ", number_1 + number_2);
    writeln("With a constant expression: ", 1000 + number_2);
}
D
arithmetic.3
結果112
定数式1100

2つの式の和がその型の最大値よりも大きい場合、オーバーフローが発生し、両方の式よりも小さい値になる。

import std.stdio;

void main() {
    // 30億ずつ
    uint number_1 = 3000000000;
    uint number_2 = 3000000000;

    writeln("maximum value of uint: ", uint.max);
    writeln("             number_1: ", number_1);
    writeln("             number_2: ", number_2);
    writeln("                  sum: ", number_1 + number_2);
    writeln("OVERFLOW! The result is not 6 billion!");
}
D
arithmetic.4
uintの最大値4294967295
number_13000000000
number_23000000000
合計1705032704
オーバーフロー! 結果は60億ではない!
減算:-

この演算子は2つの式で使用され、1つ目と2つ目の式の差を算出する。

import std.stdio;

void main() {
    int number_1 = 10;
    int number_2 = 20;

    writeln(number_1 - number_2);
    writeln(number_2 - number_1);
}
D
arithmetic.5
-10
10

実際の結果が0未満で、符号なし型で格納される場合も、やはり驚くべき結果になる。uint型を使用して、プログラムを書き直しよう。

import std.stdio;

void main() {
    uint number_1 = 10;
    uint number_2 = 20;

    writeln("PROBLEM! uint cannot have negative values:");
    writeln(number_1 - number_2);
    writeln(number_2 - number_1);
}
D
arithmetic.6
問題発生! uint型は負の値を格納できない:
4294967286
10

減算される可能性のある概念を表すには、符号付き型を使用することをお勧めする。特に理由がない限り、intを選択するとよいだろう。

乗算:*

この演算子は、2つの式の値を乗算する。結果もオーバーフローの対象となる。

import std.stdio;

void main() {
    uint number_1 = 6;
    uint number_2 = 7;

    writeln(number_1 * number_2);
}
D
arithmetic.7
42
除算:/

この演算子は、最初の式を2番目の式で割る。整数型には小数点以下の値を持つことができないため、値の小数点以下の部分は切り捨てられる。この効果は切り捨てと呼ばれる。その結果、次のプログラムは3.5ではなく3を出力する。

import std.stdio;

void main() {
    writeln(7 / 2);
}
D
arithmetic.8
3

小数部分が重要な計算では、整数ではなく浮動小数点型を使用する必要がある。浮動小数点型については、次の章で説明する。

剰余(モジュラス): %

この演算子は、最初の式を2番目の式で割り、除算の余りを返す。

import std.stdio;

void main() {
    writeln(10 % 6);
}
D
arithmetic.9
4

この演算子の一般的な用途は、値が奇数か偶数かを判断することだ。偶数を2で割った余りは常に0であるため、結果を0と比較すれば、その区別を判断するのに十分である。

if ((number % 2) == 0) {
    writeln("even number");

} else {
    writeln("odd number");
}
D
累乗: ^^

この演算子は、最初の式を2番目の式の累乗にする。例えば、3を4乗すると、3を4回乗算することになる。

import std.stdio;

void main() {
    writeln(3 ^^ 4);
}
D
arithmetic.10
81
代入を伴う算術演算

2つの式を取る演算子には、すべて代入演算子がある。これらの演算子は、結果を左側の式に代入する。

import std.stdio;

void main() {
    int number = 10;

    number += 20;  // number = number + 20; と同じ。今は30
    number -= 5;   // number = number - 5;  と同じ。今は25
    number *= 2;   // number = number * 2;  と同じ。今は50
    number /= 3;   // number = number / 3;  と同じ。今は16
    number %= 7;   // number = number % 7;  と同じ。今は 2
    number ^^= 6;  // number = number ^^ 6; と同じ。今は64

    writeln(number);
}
D
arithmetic.11
64
否定:-

この演算子は、式の値を負から正、または正から負に変換する。

import std.stdio;

void main() {
    int number_1 = 1;
    int number_2 = -2;

    writeln(-number_1);
    writeln(-number_2);
}
D
arithmetic.12
-1
2

この演算の結果の型は、式の型と同じである。符号なし型は負の値を取ることができないため、この演算子を符号なし型で使用すると、予想外の結果になる場合がある。

uint number = 1;
writeln("negation: ", -number);
D

-numberの型もuintであり、負の値を持つことはできない。

否定4294967295
プラス記号:+

この演算子は効果はなく、否定演算子との対称性のためにのみ存在する。正の値は正のままで、負の値は負のままである。

import std.stdio;

void main() {
    int number_1 = 1;
    int number_2 = -2;

    writeln(+number_1);
    writeln(+number_2);
}
D
arithmetic.13
1
-2
後増分:++

注釈:特別な理由がない限り、通常の加算演算子(プレ加算演算子とも呼ばれる)を常に使用。

通常の加算演算子とは反対に、この演算子は式の後ろに記述され、式の値を1ずつ加算する。違いは、後増分演算子は式の古い値を生成する点だ。この違いを確認するために、通常の加算演算子と比較しよう。

import std.stdio;

void main() {
    int incremented_regularly = 1;
    writeln(++incremented_regularly);      // 2を表示
    writeln(incremented_regularly);        // 2を表示

    int post_incremented = 1;

    // 増分されるが、その古い値が使用される:
    writeln(post_incremented++);           // 1を表示
    writeln(post_incremented);             // 2を表示
}
D
arithmetic.14
2
2
1
2

上記のwriteln(post_incremented++);文は、次のコードと同じである。

int old_value = post_incremented;
++post_incremented;
writeln(old_value);                    // 1を表示
D
後減算:--

注釈:特別な理由がない限り、通常の減算演算子(プレ減算演算子とも呼ばれる)を常に使用。

この演算子は、後増分演算子と同じ動作をするが、減算を行う点が異なる。

演算子の優先順位

これまでに説明した演算子は、1つまたは2つの式だけで単独で使用されてきた。しかし、論理式と同様に、これらの演算子を組み合わせてより複雑な算術式を形成することもよくある。

int value = 77;
int result = (((value + 8) * 3) / (value - 1)) % 5;
D

論理演算子と同様に、算術演算子も演算子の優先順位規則に従う。例えば、*演算子は、+演算子よりも優先される。そのため、括弧を使用しない場合(value + 8 * 3式など)、*演算子は、+演算子よりも先に評価される。その結果、この式はvalue + 24と同等になり、(value + 8) * 3とはまったく異なる結果になる。

括弧を使用することは、正しい結果を保証するためだけでなく、将来そのコードを扱うプログラマーにコードの意図を伝えるためにも有用だ。

演算子の優先順位表は、この本の後半で説明する。

オーバーフローの検出

まだ説明していない関数refパラメータを使用しているが、 core.checkedintモジュールには、オーバーフローを検出する算術関数が含まれていることをここで紹介しておこう。このモジュールでは、+-のような演算子の代わりに、addsおよびaddu(符号付きおよび符号なし加算)、mulsおよびmulu(符号付きおよび符号なし乗算)、subsおよびsubu(符号付きおよび符号なし減算)、negs(負の値の反転)という関数を使用する。

例えば、abが2つのint変数であると仮定すると、次のコードは、これらを加算した結果、オーバーフローが発生したかどうかを検出する。

import core.checkedint;

void main() {
    // テストのためにオーバーフローを発生させてみよう
    int a = int.max - 1;
    int b = 2;

    // 'adds'関数内の加算演算がオーバーフローすると、
    // この変数は'true'になる:
    bool hasOverflowed = false;
    int result = adds(a, b, hasOverflowed);

    if (hasOverflowed) {
        // オーバーフローした'result'は使用してはならない。
        // ...

    } else {
        // 'result'は使用できる。
        // ...
    }
}
D
arithmetic.15

Checkedテンプレートを定義するstd.experimental.checkedintモジュールもあるが、その使用法と実装は、この本ではまだ説明するには難しすぎる。

オーバーフローの防止

演算の結果が結果の型に収まらない場合、それ以上何もできない。最終的な結果は特定の型に収まるものの、中間計算でオーバーフローが発生し、誤った結果になる場合もある。

例として、40×60キロメートルの面積に1000平方メートルごとに1本のリンゴの木を植える必要があるとしよう。必要な木の数は?

この問題を紙で解くと、結果は40000×60000÷1000で、240万本となる。この計算を実行するプログラムを書いてみよう:

import std.stdio;

void main() {
    int width  = 40000;
    int length = 60000;
    int areaPerTree = 1000;

    int treesNeeded = width * length / areaPerTree;

    writeln("Number of trees needed: ", treesNeeded);
}
D
arithmetic.16
必要な木の本数-1894967

結果が大幅に異なるだけでなく、結果が0未満になっていることに注意。この場合、中間計算のwidth * lengthでオーバーフローが発生し、その後の/ areaPerTreeの計算で誤った結果が生成されている。

この例でオーバーフローを回避する1つの方法は、演算の順序を変更することだ。

int treesNeeded = width / areaPerTree * length ;
D

これで結果は正しくなる:

必要な木の本数2400000

この方法が機能するのは、計算のすべてのステップがint型に収まるようになったためだ。

ただし、この方法は完全な解決策ではないことに注意。この方法では、中間値が切り捨てられる可能性があり、他の特定の計算で結果に大きな影響が出る可能性があるからだ。別の解決策としては、整数型ではなく浮動小数点型を使用する方法がある。floatdouble、またはreal

切り捨ての防止

演算の順序を変更することも、切り捨ての解決策になる場合がある。切り捨ての興味深い例としては、同じ数値で除算と乗算を行う場合が挙げられる。10/9*9の結果は10になるはずだが、結果は9になる。

import std.stdio;

void main() {
    writeln(10 / 9 * 9);
}
D
arithmetic.17
9

演算順序を変更して切り捨てを回避すると、結果は正しくなる:

writeln(10 * 9 / 9);
D
10

しかし、これも完全な解決策ではない。今回は、中間計算でオーバーフローが発生する可能性がある。特定の計算で切り捨てを防ぐもう1つの解決策は、浮動小数点型を使用することだ。

演習
  1. ユーザーから2つの整数を受け取り、1つ目を2つ目で割った整数の商と余りを表示するプログラムを作成しよう。例えば、7と3が入力された場合、プログラムは次の式を表示する。
    7 = 3 * 2 + 1
  2. 余りが0の場合は、出力結果を短く表示するようにプログラムを変更しよう。例えば、10と5が入力された場合、"10 = 5 * 2 + 0"と表示されるのではなく、次のように表示されるようにしよう。
    10 = 5 * 2
  3. 4つの基本的な算術演算をサポートする簡単な計算機を作成しよう。プログラムでは、メニューから演算を選択し、入力された2つの値にその演算を適用するようにしよう。このプログラムでは、オーバーフローや切り捨ては無視してよい。
  4. 1から10までの値を、7を除き、それぞれ1行に1つずつ出力するプログラムを作成しよう。次のコードのように、行を繰り返し使用してはいけない。
    import std.stdio;
    
    void main() {
        // これをしてはいけない!
        writeln(1);
        writeln(2);
        writeln(3);
        writeln(4);
        writeln(5);
        writeln(6);
        writeln(8);
        writeln(9);
        writeln(10);
    }
    D
    arithmetic.18

    その代わりに、ループで値が加算される変数を想像しよう。ここでは、不等号演算子 !=を利用する必要があるかもしれない。