PHP入門:型・==と===・文字列・配列を実行結果で学ぶ

PHP 8.5 で実際に動かして確認。PHP 8 で変わった 0 == "a"、strict_types の TypeError、日本語の strlen と mb_strlen、配列キーの変換と foreach & の罠。

シリーズ:PHP講座 1/6(初級)。 配属先で PHP のコードを読み書きすることになった新人エンジニア向けです。読み終えると、スクリプトを実行し、PHP が値をどう比較・変換するかを予測でき、日本語の文字列を壊さずに扱い、PHP 初心者が必ず踏む配列の罠を2つ避けられるようになります。

社内の古い PHP システムを引き継いだ人、あるいは数年前の入門書で PHP を覚えた人は、「ネットの回答と動きが違う」と戸惑うことがあります。その原因の多くは、PHP 8 で == の動き、型宣言の扱い、そして「黙って通っていたミスが警告になる範囲」が変わったことにあります。以下の出力はすべて Apple Silicon Mac(M1 Pro)上の PHP 8.5.6 CLI で実際に実行した結果で、エラーメッセージ中のファイルパスだけ ~/demo/ に置き換えています。

5分で最初の PHP スクリプト

php -v でバージョンが表示されれば準備完了です(未導入なら macOS は Homebrew、Windows は windows.php.net の zip、Ubuntu は apt install php-cli php-mbstring が一般的です。この記事のために何かを入れ直してはいません)。hello.php を作ります。

<?php
echo "Hello, PHP " . PHP_VERSION . "\n";
$ php hello.php
Hello, PHP 8.5.6

ポイントは3つです。

  • PHP のファイルは 開始タグ <?php で始まります。PHP だけのファイルでは閉じタグ ?> を書きません。閉じタグの後ろの改行が余計に出力される事故を防げます。
  • echo は出力、ドット . は文字列の 連結 です。+ は数値専用です。
  • PHP_VERSION は組み込みの 定数(値が変わらない名前)です。

1行だけ試したいときは php -r 'echo 1 + 1;' が便利です。以下でも何度か使います。

変数と型

変数 は $ で始めます。型は宣言せず、値が型を持っています。get_debug_type()(PHP 8.0 以降)はエラーメッセージと同じ言葉で型名を返します。

<?php
$n = 42; $f = 3.14; $s = "42"; $b = true; $x = null; $a = [1, 2];
foreach ([$n, $f, $s, $b, $x, $a] as $v) {
    echo get_debug_type($v), " ";
}
echo "\n";
var_dump("10" + 5, "1.5" + 1, 7 / 2, intdiv(7, 2), 0.1 + 0.2 == 0.3);
int float string bool null array 
int(15)
float(2.5)
float(3.5)
int(3)
bool(false)

var_dump() は型と値を同時に表示する、学習中いちばん使う道具です。他の言語から来た人が驚くのは2点です。数値形式の文字列 "10" に + 5 すると数値に変換されて 15 になること。そして / は割り切れなければ必ず float を返すことです。整数の割り算には intdiv() を使います。0.1 + 0.2 == 0.3 が false なのは、どの言語でも同じ2進浮動小数点の誤差です。

== と ===、PHP 8 で何が変わったか

== は 緩やかな比較 で、比べる前に片方を変換することがあります。=== は 厳密な比較 で、型と値が両方同じときだけ true です。定番のケースを PHP 8.5.6 で実行しました。

<?php
$cases = [
  ['0 == "a"', 0 == "a"],        ['"1" == "01"', "1" == "01"],
  ['"10" == "1e1"', "10" == "1e1"], ['100 == "1e2"', 100 == "1e2"],
  ['null == false', null == false], ['[] == false', [] == false],
  ['"0" == false', "0" == false],  ['"" == null', "" == null],
  ['1 === "1"', 1 === "1"],        ['42 == "42abc"', 42 == "42abc"],
  ['42 == " 42"', 42 == " 42"],    ['42 == "42 "', 42 == "42 "],
];
foreach ($cases as [$label, $r]) printf("%-16s %s\n", $label, var_export($r, true));
0 == "a"         false
"1" == "01"      true
"10" == "1e1"    true
100 == "1e2"     true
null == false    true
[] == false      true
"0" == false     true
"" == null       true
1 === "1"        false
42 == "42abc"    false
42 == " 42"      true
42 == "42 "      true

1行目が有名な PHP 8 の変更点です。PHP 7 までは 0 == "a" で "a" が数値 0 に変換されて true になり、in_array("abc", [0]) まで true になって、ログイン判定などを壊していました。PHP 8.0 からは、文字列が数値形式でなければ 数値の方を文字列に変換 して比べるので false です(今回実行したのは 8.5 だけです。PHP 7 の挙動は RFC「Saner string to number comparisons」の記述によります)。

ただし残りの行も見てください。数値に見える文字列同士は今も数値として比べるので、"1" == "01" も "10" == "1e1" も true です。商品コードや会員番号、パスワードなら立派なバグです。null・false・""・"0"・[] はどれも false と緩やかに等しくなります。

流れ図。片方が int か float、もう片方が文字列の a == b から始まる。問い:文字列は "01"、"1e1"、" 42" のような数値形式か。はいなら両方を数値として比べるので、"1" == "01" や 100 == "1e2" は true。いいえなら数値を文字列に変えて文字列同士を比べるので、0 == "a" や 42 == "42abc" は false(PHP 7 では true だった)。安全な既定は変換をしない ===。
PHP 8 で数値と文字列を == で比べるときの流れ。数値形式の文字列は今も数値として比べ、数値でない文字列なら文字列同士の比較になるため 0 == "a" は false。
比べるもの 使うもの
ID・コード・トークン・ユーザー入力 ===(秘密値は hash_equals())
配列の中を探す in_array($x, $arr, true)、array_search($x, $arr, true)
値がないかどうか $x === null、または isset() / ??(第2回)
フォームから文字列で届く数値 先に (int) や filter_var で変換し、===

既定は ===。== は変換の中身を説明できるときだけ使う、と覚えておけば十分です。

declare(strict_types=1) と実際の TypeError

関数の引数と戻り値に型を書けます。既定は 型変換モード(coercive mode) で、変換できる値なら黙って変換します。

<?php
function addTax(int $price): int {
    return (int) round($price * 1.1);
}
var_dump(addTax("1000"));
var_dump(addTax(1000.0));
var_dump(addTax("1000 yen"));
int(1100)
int(1100)

Fatal error: Uncaught TypeError: addTax(): Argument #1 ($price) must be of type int, string given, called in ~/demo/loose.php on line 7 and defined in ~/demo/loose.php:2
Stack trace:
#0 ~/demo/loose.php(7): addTax('1000 yen')
#1 {main}
  thrown in ~/demo/loose.php on line 2

"1000" は何も言われずに 1000 になりました。ファイルの 最初の文 に declare(strict_types=1); を書くと、同じ呼び出しが拒否されます。

<?php
declare(strict_types=1);

function addTax(int $price): int {
    return (int) round($price * 1.1);
}
var_dump(addTax(1000));
var_dump(addTax("1000"));
int(1100)

Fatal error: Uncaught TypeError: addTax(): Argument #1 ($price) must be of type int, string given, called in ~/demo/strict.php on line 8 and defined in ~/demo/strict.php:4

読み方:エラーの種類は TypeError、「Argument #1 ($price)」がどの引数か、「called in … line 8」が悪い値を渡した場所です。直すのは関数ではなく呼び出し側です。

初心者が見落としやすい点が2つあります。

  • strict_types は 宣言したファイルから行う呼び出し に効きます。関数が定義されたファイルではありません。strict なライブラリでも、strict でないファイルから呼べば変換されます。
  • strict モードでも int → float は許されます(float $x に 5 を渡すと float(5))。逆に型変換モードで int 引数に 1.5 を渡すと、Deprecated: Implicit conversion from float 1.5 to int loses precision が出て値は int(1) になります。この Deprecated は PHP 8.1 で追加されました。

ターミナルでは同じエラーが PHP 付きでもう一度出ることがあります。CLI がログ(標準エラー)と画面の両方に書いているためで、記事では重複を省きました。

文字列:引用符・変数展開・ヒアドキュメント

<?php
$name = "Mina";
$n = 3;
echo 'Hi $name\n', "\n";
echo "Hi $name, you have {$n} new messages\n";
$user = ['name' => 'Mina'];
echo "Hi {$user['name']}\n";
echo <<<TXT
    Dear $name,
      You have {$n} items.
    TXT;
echo "\n";
echo <<<'RAW'
    No $interpolation here\n
    RAW;
Hi $name\n
Hi Mina, you have 3 new messages
Hi Mina
Dear Mina,
  You have 3 items.
No $interpolation here\n
  • シングルクォート:ほぼ何も解釈しません。$name も \n も書いたまま。
  • ダブルクォート:変数が 展開(値に置き換え)され、\n などのエスケープが効きます。単純な変数以外は {…} で囲みます。
  • ヒアドキュメント <<<TXT は複数行のダブルクォート文字列、Nowdoc <<<'RAW' はそのシングルクォート版です。PHP 7.3 から終了マーカーをインデントでき、その分のインデントが各行から取り除かれます。

日本語の文字列:strlen と mb_strlen

PHP の文字列は バイト列 です。strlen() はバイト数を数えます。人が読む文字列には mbstring 拡張の mb_(マルチバイト)関数が必要です。

<?php
foreach (["hello", "こんにちは", "안녕하세요", "👍🏽"] as $s) {
    printf("%-12s strlen=%2d  mb_strlen=%d\n", $s, strlen($s), mb_strlen($s));
}
$s = "こんにちは";
var_dump(substr($s, 0, 4), mb_substr($s, 0, 2));
var_dump(strrev("안녕"));
var_dump(mb_strwidth("こんにちは"), grapheme_strlen("👍🏽"));
hello        strlen= 5  mb_strlen=5
こんにちは strlen=15  mb_strlen=5
안녕하세요 strlen=15  mb_strlen=5
👍🏽     strlen= 8  mb_strlen=2
string(4) "こ�"
string(6) "こん"
string(6) "��눕�"
int(10)
int(1)

出力そのものが証拠です。printf("%-12s") は バイト数 で桁をそろえるので、日本語と韓国語の行だけずれています。かな・漢字は UTF-8 で1文字3バイト。substr($s, 0, 4) は2文字目を途中で切って不正なバイト(�)を作り、strrev は文字ではなくバイトを逆順にしました。肌の色付きの絵文字はコードポイント2つ(mb_strlen = 2)ですが見た目は1文字で、1と答えるのは intl 拡張の grapheme_strlen() だけです。mb_strwidth は全角を幅2として数えるので、固定幅の帳票レイアウトに使えます。

実務のルール:氏名の文字数制限、「先頭20文字」のプレビュー、英字以外の大文字化には、mb_strlen・mb_substr・mb_strtoupper を使います。新しい関数もあります。mb_str_pad(8.3)、mb_trim と mb_ucfirst(8.4)です。8.5 で試すと、trim(" 全角スペース ") は全角スペースを残し、mb_trim は取り除きました。フォームでよく混ざる全角スペース対策に効きます。

配列は「順序付きマップ」

PHP の 配列 は C や Java のリストとは違い、順序付きマップ です。キー(int か string)と値の組を、入れた順に保持します。罠は、PHP が一部のキーを勝手に変換することです。

<?php
$a = [];
$a["1"]   = "string one";
$a[1]     = "int one";
$a[true]  = "bool true";
$a["01"]  = "string 01";
$a[1.7]   = "float 1.7";
$a[null]  = "null";
$a["1.5"] = "string 1.5";
var_dump($a);
Deprecated: Implicit conversion from float 1.7 to int loses precision in ~/demo/arr.php on line 7

Deprecated: Using null as an array offset is deprecated, use an empty string instead in ~/demo/arr.php on line 8
array(4) {
  [1]=>
  string(9) "float 1.7"
  ["01"]=>
  string(9) "string 01"
  [""]=>
  string(4) "null"
  ["1.5"]=>
  string(10) "string 1.5"
}

7回代入して、キーは4つ。"1"・1・true・1.7 はすべて整数キー 1 になり、前の値を上書きしました。"01" と "1.5" は正規の整数表記ではないので文字列のまま。null は "" になり、PHP 8.5 ではこれに Deprecated が出るようになりました。

実務で効いてくるのはここです。DB から取った会員 ID("100"、"200")をキーにすると整数キーになり、array_merge() は整数キーを振り直します。

<?php
$byId = ["100" => "Alice", "200" => "Bob"];
print_r(array_merge($byId, ["300" => "Chen"]));
print_r($byId + ["300" => "Chen"]);
Array
(
    [0] => Alice
    [1] => Bob
    [2] => Chen
)
Array
(
    [100] => Alice
    [200] => Bob
    [300] => Chen
)

キーを残したいなら +(または array_replace)を使います。また ["a", "b", "c"] に unset($list[1]) するとキーは 0 と 2 だけになり、array_is_list()(8.1 以降)は false、次の $list[] = "d" はキー 3 に入りました。番号を詰めるには array_values() です。

よくある間違い:foreach の参照で最後の要素が重複する

全商品の価格を2倍にしてから表示したい、というよくあるコードです。

<?php
$prices = [100, 200, 300];
foreach ($prices as &$p) {
    $p = $p * 2;
}
foreach ($prices as $p) {
    echo $p, " | ";
}
echo "\n";
print_r($prices);
200 | 400 | 400 | 
Array
(
    [0] => 200
    [1] => 400
    [2] => 400
)

実際に起きていること: &$p によって $p は各要素への 参照(同じ記憶領域の別名)になります。1つ目のループが終わっても $p は $prices[2] への参照のままです。2つ目のループは各要素を「$p に」代入するので、実際には $prices[2] に 200、400 と書き込み、最後は $prices[2] を自分自身にコピーします。600 は警告もなく消えます。

流れ図。1つ目のループ foreach ($prices as &$p) で [100, 200, 300] が [200, 400, 600] になる。終了後も $p は $prices[2] に結び付いたまま。2つ目のループ foreach ($prices as $p) は 200、400、そして $prices[2] 自身の値を同じ場所に代入するので、配列は [200, 400, 400] になる。修正の分岐では1つ目のループの後に unset($p) を呼び、2つ目のループは 200 400 600 を表示する。
foreach の参照の罠。1つ目のループの後も $p は最後の要素への参照のままなので、2つ目のループが毎回 $prices[2] に書き込む。unset($p) でつながりを切る。

修正: 参照ループの直後に unset($p);。そもそも参照を使わない方がより安全です。

<?php
$prices = [100, 200, 300];
$doubled = array_map(fn($x) => $x * 2, $prices);          // new array
foreach ($prices as $k => $v) { $prices[$k] = $v * 2; }   // in place, by key
echo implode(" ", $doubled), " / ", implode(" ", $prices), "\n";
200 400 600 / 200 400 600

未定義の変数・キーの Warning

PHP 8 では、「存在しないものを使った」ミスの多くが、目立たない Notice から Warning に格上げされました。スクリプトは止まらないので、警告をきちんと読む必要があります。

<?php
echo $count + 1, "\n";
$user = ["name" => "Mina"];
echo $user["email"], "|\n";
$list = [10, 20];
echo $list[5], "|\n";
$s = "abc";
echo $s[10], "|\n";
$total = null;
echo $total["sum"], "|\n";
echo $user["email"] ?? "(no email)", "\n";
Warning: Undefined variable $count in ~/demo/undef.php on line 2
1

Warning: Undefined array key "email" in ~/demo/undef.php on line 4
|

Warning: Undefined array key 5 in ~/demo/undef.php on line 6
|

Warning: Uninitialized string offset 10 in ~/demo/undef.php on line 8
|

Warning: Trying to access array offset on null in ~/demo/undef.php on line 10
|
(no email)

未定義の $count は null として扱われ、null + 1 で 1 が出ました。変数名の打ち間違いがクラッシュではなく「合計値が違う」というバグになる仕組みです。最後の行が、正当に存在しないこともあるキーへの対処法、null 合体演算子 ?? です。左辺が存在しないか null なら右辺を返し、警告は出ません。第2回でフォーム入力に使います。

実測:文字列の組み立てと配列の検索

どの習慣が効くのかを知るための小さな計測です。条件:数値文字列10万件(mt_srand(42))、各ケース7回実行の中央値、PHP 8.5.6 CLI、Apple Silicon Mac(M1 Pro)。CLI では OPcache と JIT が既定で無効で、その状態で測っています。JIT を有効にした実行でも大差はありませんでした(in_array が 53〜54 ms から 47.9 ms、他は 0.1 ms 以内の差)。

<?php
mt_srand(42);
$words = [];
for ($i = 0; $i < 100_000; $i++) $words[] = (string) mt_rand(1, 999_999);

function bench(callable $fn, int $runs = 7): float {
    $t = [];
    for ($r = 0; $r < $runs; $r++) {
        $s = hrtime(true); $fn(); $t[] = (hrtime(true) - $s) / 1e6;
    }
    sort($t);
    return $t[intdiv($runs, 2)];
}
printf("concat: %.3f ms\n", bench(function () use ($words) {
    $out = ''; foreach ($words as $w) { $out .= $w . ','; } return $out; }));
printf("implode: %.3f ms\n", bench(fn() => implode(',', $words)));
// 実際のファイルでは foreach 合計と array_sum、in_array と isset も計測
処理(10万件) 中央値・1回目 中央値・2回目
ループ内で .= 2.872 ms 2.856 ms
implode() 0.784 ms 0.775 ms
foreach で合計 2.354 ms 2.363 ms
array_sum() 1.811 ms 1.811 ms
1万件の配列に in_array を1,000回 53.29 ms 54.10 ms
array_flip した配列に isset を1,000回 0.021 ms 0.022 ms
10万件の処理時間の中央値を示す棒グラフ。ループ内の .= 連結が 2.87 ms、implode が 0.78 ms、foreach での合計が 2.35 ms、array_sum が 1.81 ms。
数値文字列10万件(mt_srand(42))で7回測った中央値。PHP 8.5.6 CLI、OPcache/JIT なし、Apple M1 Pro。図外:1,000回の検索は in_array で 53.3 ms、array_flip した配列の isset で 0.021 ms。

implode() は .= の約3.7倍、array_sum() はループの約1.3倍速いという結果でした。差は本物ですが、体感できるのは数十万件規模からです。本当に効くのは最後の組です。in_array は毎回リスト全体をなめますが、array_flip() で作った配列への isset($set[$key]) はハッシュ検索なので、今回は約 2,500倍 速く、リストが長いほど差が広がります(前述のキー変換により、数値文字列は flip 後に整数キーになります。isset では問題ありませんが、キーを取り出して使うときは注意)。

正直な限界・バージョンの注記

  • すべて Apple M1 Pro の macOS 上、PHP 8.5.6 CLI(Homebrew ビルド、mbstring と intl あり)で実行しました。PHP 7 や古い 8.x は 実行していません。0 == "a" の PHP 7 での結果と以下の「追加バージョン」は、PHP マニュアルと RFC に基づく記述です。
  • バージョン:文字列と数値の比較変更・get_debug_type()・未定義変数/キーの Warning 化(8.0)、float→int の Deprecated・array_is_list()(8.1)、mb_str_pad(8.3)、mb_trim・mb_ucfirst(8.4)、「Using null as an array offset is deprecated」(8.5)。strict_types は 7.0 からあります。
  • 計測は1台のマシンでの7回の中央値で、共有サーバーでは値が変わります。CLI 既定どおり OPcache なしで測りました。OPcache と JIT は第6回で改めて測ります。
  • grapheme_strlen には intl 拡張が必要です。格安レンタルサーバーでは無効なこともあるので php -m で確認してください。

練習問題

1. 実行せずに結果を予想し、php -r で確かめてください:"abc" == 0、"1" == "01"、"10" == "1e1"、null == false、"abc" == "ABC"。

解答

false、true、true、true、false(PHP 8.5.6)。"abc" == 0 は PHP 8 から false。数値形式の文字列2つは数値として比べられます。null == false は bool としての緩やかな比較。文字列比較は大文字小文字を区別します。=== なら5つとも false です。

2. 会員登録フォームで「氏名は10文字まで」を strlen($name) <= 10 で判定しています。8文字の「たなかはなこさん」が弾かれるのはなぜで、どう直しますか。

解答

strlen("たなかはなこさん") は 24 バイト(8 × 3)なので判定に落ちます。mb_strlen($name) <= 10 に直します。mb_strlen を使った fits() 関数で試すと、この名前に true を返しました。

3. ["1" => "a", 1 => "b", "1.0" => "c", true => "d"] の要素数と、キーは何になりますか。

解答

2つです。"1"・1・true はどれも整数キー 1 になり、最終的な値は "d"。"1.0" は正規の整数表記ではないので文字列キーのままです。print_r は [1] => d と [1.0] => c を表示しました。

次回へ

よくある質問

PHP 8 で 0 == "a" は true ですか、false ですか?

false です。PHP 8.0 から、数値と「数値形式でない文字列」を比べるときは数値の方を文字列に変換して文字列同士で比べます。PHP 7 では "a" が 0 に変換されて true でした。ただし数値形式の文字列同士は今も数値として比べるので、PHP 8.5 でも "1" == "01" と "10" == "1e1" は true です。だから既定は === にします。

5文字の日本語なのに strlen() が 15 を返すのはなぜですか?

strlen() は文字数ではなくバイト数を数えるからです。UTF-8 ではひらがな・漢字は1文字3バイトなので、こんにちは は strlen=15、mb_strlen=5 でした。画面に出す文字列には mb_strlen や mb_substr などの mb_ 関数を使います。

foreach の後で配列の最後の要素が重複するのはなぜですか?

foreach ($a as &$v) の後も $v は最後の要素への参照のまま残ります。続く foreach ($a as $v) が毎回その最後の要素に値を書き込むため、[200, 400, 600] が [200, 400, 400] になりました。参照ループの直後に unset($v) を書くか、array_map やキー指定の代入で参照を使わないようにします。

  • #PHP
  • #初級
  • #型
  • #strict_types
  • #mbstring
  • #配列

t.laboratory

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

運営者情報 · アプリ

↑