PHPの%演算子は負の数で負の値を返す曜日ローテーションが範囲外になる理由とfmodとの違い
PHPの%は剰余を求める演算子ですが、結果の符号は割られる数(左辺)に従うため、-7 % 3 は 2 ではなく -1 になります。小数は整数に切り捨てられ、0で割るとDivisionByZeroErrorです。
「担当者を日替わりでローテーションする」「ページ番号を巻き戻して循環させる」といった処理で、添字に%を使うことはよくあると思います。正の数だけなら何の問題もないんですが、前の週に戻るような負の値が混ざった瞬間に、存在しない添字を引いて壊れます。私も一度、先週分の担当表だけ空欄になって首をかしげたことがあります。
-7 % 3 はなぜ 2 にならないのか
数学の剰余は常に0以上になる流儀が多いので、-7を3で割った余りは2、と考えたくなります。ただPHPの%は、結果の符号が左辺と同じになります。左辺が負なら結果は0以下、左辺が正なら0以上です。右辺の符号は結果に影響しません。実際に動かすとこうなります。
<?php
var_dump(-7 % 3); // int(-1)
var_dump(7 % -3); // int(1)
var_dump(-7 % -3); // int(-1)
この挙動はCやJavaなど多くの言語と同じです。Pythonの%が正の値を返すのに慣れていると、逆に戸惑うかもしれません。言語ごとに違うので、PHPではこうなる、と覚えるしかないところです。
循環する添字は剰余を2回かけて正に戻す
負の数が入りうる循環処理では、もう一度nを足して、もう一度%をかける書き方が定番です。1回目の%で-(n-1)から(n-1)の範囲に収め、nを足して0以上にそろえ、最後の%でnちょうどの値を0に戻します。
<?php
function mod(int $a, int $n): int
{
return (($a % $n) + $n) % $n;
}
$members = ['A', 'B', 'C'];
foreach ([-1, 0, 1, 2, 3] as $week) {
echo $week, ':', $members[mod($week, count($members))], ' ';
}
// -1:C 0:A 1:B 2:C 3:A
-1週目がちゃんと末尾のCに回り込んでくれます。素の%だと添字が-1になり、Undefined array keyの警告と空欄が出ます。ローテーションや時刻の巻き戻しなど、負の方向に動く可能性があるなら、最初からこの関数を一つ用意しておくのが楽です。
%は小数を整数にしてから計算する
もうひとつ見落としやすいのが、%のオペランドは先に整数へ変換されるという点です。小数部は切り捨てられるので、小数の余りを求めたつもりでも、整数の計算になっています。
<?php
var_dump(7.9 % 3); // int(1) 7 % 3 として計算される
var_dump(10 % 3.99); // int(1) 10 % 3 として計算される
var_dump(fmod(7.5, 2)); // float(1.5)
var_dump(fmod(-7, 3)); // float(-1)
小数の剰余が欲しいときはfmodを使います。戻り値はfloatで、符号が左辺に従う点は%と同じです。なので、fmodでも負の数の巻き戻しは別途考える必要があります。ついでに言うと、小数で割り切れるかを判定する用途は、0.1 + 0.2 == 0.3 がfalseになるのと同じ理由で誤差が出ます。たとえばfmod(0.3, 0.1)は0ではなく約0.09999999999999998になるので、金額なら整数(円やセント)に直してから%で割る、という運用が安全だと思います。
0で割ったときはどうなる?
PHP 8では、%の右辺が0だとDivisionByZeroError(メッセージは Modulo by zero)が投げられます。intdivを0で割った場合も同じ例外クラスですが、メッセージは Division by zero です。一方でfmodは例外を出さず、NANを返します。
<?php
try { echo 5 % 0; }
catch (\DivisionByZeroError $e) { echo $e->getMessage(); } // Modulo by zero
try { echo intdiv(5, 0); }
catch (\DivisionByZeroError $e) { echo $e->getMessage(); } // Division by zero
var_dump(fmod(5, 0)); // float(NAN)
ユーザー入力で割る数が決まるような場面では、%を使う前に0でないことを確認しておきます。fmodのNANは黙って後ろへ流れていくので、例外で止まる%より気づきにくい、というのが個人的な印象です。
まとめ
%の結果は左辺の符号に従うので、負の数が入る循環処理では((a % n) + n) % n の形にしておくと安心です。小数が絡むならfmod、0除算は%とintdivが例外、fmodはNANと、挙動が少しずつ違うところを押さえておけば、添字まわりの地味な事故はかなり減ると思います。
よくある質問
Q. PHPで-7 % 3 の結果はいくつですか?
A. -1 です。%の結果の符号は左辺(割られる数)と同じになります。常に0以上の余りが欲しい場合は ((-7 % 3) + 3) % 3 のように補正します。
Q. 小数の余りを求めるにはどうしますか?
A. fmodを使います。%はオペランドを整数にしてから計算するため、7.9 % 3 は 7 % 3 と同じ結果になります。
Q. 0で割るとどうなりますか?
A. PHP 8では、%もintdivもDivisionByZeroErrorを投げます。fmodだけは例外を投げずNANを返します。