C#の演算子:オーバーフロー・??・負の%・シフトの罠を.NET 10で検証

C#の算術・関係・論理・ビット演算子を.NET 10で再検証。オーバーフローとchecked、??と??=、負の剰余、整数の割り算、シフト回数のマスクを実際の出力とエラーコードで確認し、checkedのコストも計測。

2026年11月改訂: 2021年に韓国語で書いた演算子の講座を、日本語向けに書き直しました。例を .NET 10.0.14(C# 14)でコンパイルし直したところ、結果はすべて2021年と同じでした。2021年版に欠けていたのは、実務で演算子が問題を起こす場面です。黙って一周する整数オーバーフロー、負の数と %、1 << 32、?? と文字列連結の優先順位の罠を、実際の出力とともに加えました。元の表で --・- が —・– になっていたのはブログエディターの文字変換のせいで、以下のコードは正しい文字に直しています。

C# を学び始めた人と、基本の演算子は知っているけれど「エラーを出さずに間違った数を返す演算子はどれか」を知りたい開発者向けの記事です。先に結論を書くと、整数の +・-・* は checked を使わない限りあふれると一周し、整数同士の / は小数部を捨て、% は左オペランドの符号に従い、int のシフト回数は5ビットに切り詰められます。それぞれ実際の出力で示し、checked のコストも測りました。

算術・インクリメント・代入:2021年の例を .NET 10 で

演算子の分類は2021年の表と同じです。算術(+ - * / %)、インクリメント/デクリメント(++ --)、代入(= += -= *= /= %= など)、論理(&& || !)、関係(< > == != >= <=)、ビット(& | ^)で、元の表になかった ~ << >> >>> もビット演算子です。

元の算術の例をトップレベルステートメントに書き直すと次のとおりです。

int a = 55, b = 6;
Console.WriteLine(a + b);
Console.WriteLine(a - b);
Console.WriteLine(a * b);
Console.WriteLine(a / b);
Console.WriteLine(a % b);
61
49
330
9
1

インクリメントの例も2021年と同じ4行を出力しました。

int a = 1;
Console.WriteLine(++a);  // 2
Console.WriteLine(a++);  // 2、そのあと a は3
Console.WriteLine(--a);  // 2
Console.WriteLine(a--);  // 2、そのあと a は1

元の記事は、前置は「文を実行する前に」、後置は「文を実行したあとに」値を変えると説明していました。正確には、どちらもその場ですぐ値を変え、違うのは式の値です。++a は変更後の値、a++ は変更前の値になります。この違いは i = i++ + ++i; のようなコードで表に出て、5から始めると12(5 + 7)になります。こういうコードは行を分けて書くべきです。

代入の例も 110 50 3000 50 50 で同じでした。複合代入には1つ知っておきたい点があります。byte 変数に b += 1 はコンパイルできますが、b = b + 1 は error CS0266 です。b + 1 は int で、複合代入はキャストを自動で入れてくれるからです。そのキャストがオーバーフローも隠します。255の入った byte に b++ すると 0 になります。

整数の割り算、%、そして負の数

整数同士で割ると結果も整数で、小数部は0方向に切り捨てられます。

int seven = 7;
Console.WriteLine($"{seven / 2} {-seven / 2} {seven / 2.0} {(double)seven / 2} {(double)(seven / 2)}");
3 -3 3.5 3.5 3

最後の値はよくあるバグです。(double)(seven / 2) は整数の割り算を先に行い、その結果の 3 をキャストします。キャストするのは結果ではなくオペランドの片方です。平均点や達成率を出すコードでよく見かけます。

割り算が0方向に切り捨てるので、% の結果は左オペランドの符号に従います。

-7 % 3  = -1
7 % -3  = 1
-7 % -3 = -1
5.5 % 2 = 1.5
-5.5 % 2 = -1.5

Python では -7 % 3 は 2 ですが、C# は C・Java・JavaScript と同じく -1 です。実務で困るのは次の2点です。

  • x % 2 == 1 は負の数で奇数判定になりません。-7 % 2 は -1 です。x % 2 != 0 か int.IsOddInteger(x)(.NET 7 以降)を使います。2021年の if・switch の記事 にある switch (a % 2) の例も、a = -13 だとどの case にも当たりませんでした。
  • リングバッファのようにインデックスを巻き戻すなら ((i % n) + n) % n です。-7 と 3 なら 2 になりました。

0での割り算は型によって違います。int 変数で 7 / 0 を実行すると DivideByZeroException: Attempted to divide by zero. が発生し、定数同士ならコンパイラが error CS0020: Division by constant zero で止めます。double は例外なしで 1.0 / 0 が ∞、0.0 / 0 が NaN でした。もう1つ、int.MinValue / -1 と int.MinValue % -1 はどちらも OverflowException でした。数学的に正しい結果の 2,147,483,648 が int に入らないためです。

オーバーフロー:checked、unchecked、そしてコンパイラ

実務のバグにもっともつながりやすい演算子の挙動です。C# の整数演算は既定で unchecked なので、結果は型の幅に切り詰められ、何も知らせてくれません。

int max = int.MaxValue;
Console.WriteLine(max + 1);
Console.WriteLine(unchecked(max + 1));
Console.WriteLine(checked(max + 1));
-2147483648
-2147483648
Unhandled exception. System.OverflowException: Arithmetic operation resulted in an overflow.
判断図。出発点は a + b のような int の演算。問:結果が int に収まるか。はいなら通常の値。いいえならコンテキストで分かれる。int.MaxValue + 1 のような定数式はコンパイル時に error CS0220。checked コンテキストでは OverflowException。既定の unchecked では値が一周し、int.MaxValue + 1 は -2147483648 になる。
int の結果が収まらないときに何が起きるか(.NET 10 で確認)。定数式はコンパイルエラー、checked は例外、既定の unchecked は黙って一周する。

コンパイラがオーバーフローを捕まえてくれるのは定数式だけです。int b = int.MaxValue + 1; は error CS0220: The operation overflows at compile time in checked mode で、unchecked(...) で囲むとコンパイルでき、一周した値が入ります。

現場でよく見るのは、long に入れる前にすでにあふれているケースです。

int count = 50_000, size = 50_000;
long wrong = count * size;          // int * int のあとで long に広げる
long right = (long)count * size;
Console.WriteLine($"{wrong} {right}");
-1794967296 2500000000

テスト中に確かめたほかのオーバーフローの例です。

式 .NET 10 での結果
(int)3_000_000_000L -1294967296(上位ビットを捨てる)
(int)3e10(double → int) 2147483647(飽和)
(int)double.NaN 0
checked((int)3e10) OverflowException
Math.Abs(int.MinValue) OverflowException: Negating the minimum value of a twos complement number is invalid.
-int.MinValue -2147483648

double → int の行には注意が必要です。.NET 9 から、範囲外の浮動小数点 → 整数変換はすべてのプラットフォームで飽和するように変わりました。古い記事で -2147483648 のようなおかしな値が出ていたなら、それは変更前の x86/x64 の挙動です。(int)double.NaN を定数として直接書くと、コンパイラは error CS0221: Constant value 'NaN' cannot be converted to a 'int' (use 'unchecked' syntax to override) を出します。

実測:checked はどれくらい遅いか

checked を避ける理由としてよく挙がるのが速度です。要素100万個の int 配列を300回合計する(加算3億回)ループを、unchecked(s + d[i]) と checked(s + d[i]) でそれぞれ [MethodImpl(NoInlining)] のメソッドにし、Release ビルド、Apple Silicon Mac(M1 Pro)でウォームアップ1回のあと5回計測しました。

ループ 1回目 2回目 3回目 4回目 5回目
unchecked の int 合計 129.2ms 128.3ms 109.3ms 106.2ms 107.5ms
checked の int 合計 133.7ms 102.2ms 99.6ms 97.0ms 99.1ms

このループでは、オーバーフロー検査のコストは計測のばらつきに埋もれました。加算ごとに付く、一度も分岐しない条件ジャンプ1つは、メモリ読み込みに比べればほぼタダです。どのループでもタダだとは言いませんが、「遅いから」は checked を外す既定の理由にはなりにくいでしょう。金額・サイズ・ID を数えるコードなら、プロジェクト全体で <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> を有効にし、ハッシュ関数のように一周が意図どおりの箇所だけ unchecked にする方法も検討に値します。

ビット演算子とシフト回数の罠

元のビット演算の例は、今も 5 & 10、5 | 10、5 ^ 10 に対して 0、15、15 を出力します。元の記事で扱っていなかった演算子は次のとおりです。

int one = 1, s32 = 32, s33 = 33, neg = -16;
Console.WriteLine($"{one << 31} {one << s32} {one << s33} {1L << s32}");
Console.WriteLine($"{neg >> 2} {neg >>> 2} {~5} {5 & ~1}");
-2147483648 1 2 4294967296
-4 1073741820 -6 4

注目する点は3つです。

  1. シフト回数は切り詰められます。 int・uint では回数の下位5ビット、long・ulong では下位6ビットだけが使われます。だから 1 << 32 は 1 << 0 で、1L << 64 も 1 でした。定数で int c = 1 << 32; と書いてもエラーも警告もなく 1 になります。ビット幅を変数で受け取ってマスクを作るなら、幅を自分で検査する必要があります。
  2. 符号付き型の >> は算術シフトで、符号ビットをコピーします。-16 >> 2 は -4 です。C# 11 で0を詰める符号なし右シフト >>> が加わり、-16 >>> 2 は 1073741820 です。それ以前は uint にキャストする必要がありました。
  3. ~ はすべてのビットを反転します。~5 は -6、x & ~1 は最下位ビットを消します。

bool に使う & と | は両辺を必ず評価する論理演算子で、&& と || は短絡評価です。呼び出し回数を数える Hit() メソッドで確かめると、false && Hit() のあとは0回、false & Hit() のあとは1回でした。

null 演算子:??、??=、?.

古い入門書が前提にしていた文法のあとに追加されましたが、今ではほとんどのコードに出てくる演算子です。

string? name = null;
Console.WriteLine(name ?? "guest");           // guest
Console.WriteLine(name?.Length);              // 空行(null)
int? len = name?.Length;                      // int?、HasValue = False
List<int>? cache = null;
cache ??= new List<int>();                    // null のときだけ代入(C# 8)
cache.Add(1);
cache ??= new List<int> { 9, 9 };             // null ではないので無視
Console.WriteLine(cache.Count);               // 1
  • a ?? b は a が null でなければ a を返します。null になり得ない値型に使うと error CS0019: Operator '??' cannot be applied to operands of type 'int' and 'int' です。
  • x?.Member や arr?[0] は、左側が null なら例外の代わりに null を返します。結果の型も null 許容になり、name?.Length は int? です。
  • x ??= y(C# 8)は x が null のときだけ代入します。遅延初期化の定番の書き方です。

優先順位の罠

* が + より先なのと同じく、優先順位の大半は数学どおりなので困りません。バグを生むのは優先順位の低い演算子です。

優先順位順に12個の箱をつないだ図:一次式(x.y、x?.y、f(x)、a[i]、x++)→単項(!、~、-x、++x、キャスト)→乗除(*、/、%)→加減(+、-)→シフト(<<、>>、>>>)→関係・型検査(<、>、<=、>=、is、as)→等値(==、!=)→ビット AND、XOR、OR の順→条件 AND、条件 OR の順→null 合体(??)→条件演算子(c ? a : b)→代入とラムダ(=、+=、??=、=>)。
C# の演算子の優先順位。左上がもっとも強く、右下がもっとも弱い。矢印の手前にある演算子ほど先に被演算子と結びつく。
int? x = 1; int y = 10, z = 5;
Console.WriteLine(x ?? y + z);       // 1   (x ?? (y + z))
Console.WriteLine((x ?? y) + z);     // 6
Console.WriteLine("Sum: " + 1 + 2);  // Sum: 12
Console.WriteLine($"Sum: {1 + 2}");  // Sum: 3
Console.WriteLine(1 + 2 << 1);       // 6   (+ が << より先)
bool t = true, f = false;
Console.WriteLine(t || f && f);      // True  (&& が || より先)
  • ?? は算術演算子より弱いです。count ?? 0 + 1 は (count ?? 0) + 1 ではなく count ?? 1 です。
  • 左に文字列があると、続く + は左から順にすべて文字列連結になります。文字列補間を使いましょう。
  • x & 1 == 0 は (x & 1) == 0 ではありません。== のほうが強いので x & (1 == 0) になり、error CS0019: Operator '&' cannot be applied to operands of type 'int' and 'bool' です。(x & 1) == 0 と書きます。
  • && は || より強いです。括弧なしで混ぜても文法上は問題ありませんが、レビューしにくくなります。

迷ったら括弧を付けましょう。括弧はコンパイラが消してくれますし、レビューする人は助かります。

まとめ:場面別の対処

場面 対処
結果が型の範囲を超えうる checked(...) か <CheckForOverflowUnderflow>、掛ける前に広げる((long)a * b)
整数の平均・割合 オペランドの片方をキャスト:(double)sum / count
偶奇の判定 x % 2 != 0 か int.IsOddInteger(x)(== 1 は使わない)
巻き戻すインデックス ((i % n) + n) % n
幅が変数のビットマスク long をシフト(1L << n)し、n を検査
int の符号なし右シフト >>>(C# 11)
null のときの既定値 ??、遅延初期化は ??=
??・+・&・== を混ぜるとき 括弧

型と範囲は前の段階の C#の変数・データ型・定数 で、関係・論理演算子の実際の使い方は if と switch で、OverflowException などの例外処理は C#の例外処理 で扱っています。

検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14(net10.0 の既定)、macOS、Apple Silicon Mac(M1 Pro)。

よくある質問

C#で int.MaxValue + 1 が負の数になるのはなぜ?

整数演算の既定は unchecked なので、範囲を超えた結果は一周します。.NET 10 で int.MaxValue + 1 は -2147483648 でした。checked(...) で囲むか、プロジェクトで <CheckForOverflowUnderflow> を有効にすると OverflowException になります。コンパイル時定数の式なら、コンパイラが error CS0220 で止めます。

C#で -7 % 3 が2ではなく-1になるのはなぜ?

C# の整数除算は0方向に切り捨てるので(-7 / 3 は -2)、剰余は左オペランドの符号に従います。実測で -7 % 3 は -1、7 % -3 は 1 でした。常に0〜n-1の値がほしいときは ((a % n) + n) % n と書きます。

int で 1 << 32 が1になるのはなぜ?

int と uint ではシフト回数の下位5ビットだけが使われます(long と ulong は下位6ビット)。32 & 31 は0なので 1 << 32 は 1 << 0 = 1、1 << 33 は2です。定数の 1 << 32 でもコンパイラは警告しません。64ビットの結果が必要なら 1L << 32 のように long をシフトすると 4294967296 になります。

  • #C#
  • #演算子
  • #オーバーフロー
  • #checked
  • #null合体演算子
  • #.NET

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑