PHPの ??(null合体演算子)とは、左側が「未定義」または「null」のときだけ右側の値を返す演算子です。 $name = $_GET['name'] ?? 'guest'; のように書くと、キーが無くてもWarningを出さずに既定値へ切り替えられます。isset() と三項演算子を組み合わせた書き方を、1つの記号にまとめたものと考えると理解しやすくなります。

この記事では、?? と見た目が似ている ?:(エルビス演算子)、??=(null合体代入演算子)、?->(nullsafe演算子)の違いを、PHP 8.4.21 で実際に動かした出力と一緒に整理します。null・未定義・0・空文字・false・空配列の6種類の値に対して、??、?:、isset、empty がそれぞれ何を返すかを1枚の表にし、"文字列" . $a ?? "x" が思ったとおりに動かない理由(演算子の優先順位)と括弧での直し方まで扱います。

??・?:・??=・?->の早見表

まずは4つの演算子の役割を一覧で確認します。「何を判定して切り替えるか」が演算子ごとに違う点が最大のポイントです。

演算子 呼び方 右側に切り替わる条件 未定義でのWarning 使えるバージョン 代表的な書き方
?? null合体演算子 左が未定義、またはnull 出ない PHP 7.0以降 $a = $_GET['q'] ?? '';
?: エルビス演算子(三項演算子の省略形) 左がfalsy(null、0、"0"、""、false、[]) 出る PHP 5.3以降 $a = $name ?: '名無し';
??= null合体代入演算子 左が未定義、またはnullのときだけ代入 出ない PHP 7.4以降 $opt['page'] ??= 1;
?-> nullsafe演算子 左がnullなら以降の呼び出しを打ち切ってnullを返す 未定義変数では出る PHP 8.0以降 $user?->address?->city

値ごとの結果は次のとおりです(PHP 8.4.21 での実行結果。スクリプトは次の章に載せています)。

左側の値 値 ?? "x" 値 ?: "x" isset(値) empty(値)
null 'x' 'x' false true
未定義の変数 'x'(Warningなし) 'x'(Warningあり) false true
0 0 'x' true true
"0" '0' 'x' true true
""(空文字) '' 'x' true true
false false 'x' true true
[](空配列) [] 'x' true true
"abc" 'abc' 'abc' true false

?? は「nullか未定義か」だけを見て、?: は「falsyかどうか」を見ます。 0や空文字をそのまま残したいなら ??、0や空文字も既定値に置き換えたいなら ?: を選びます。

値ごとの結果を1枚の表で確かめる(実行結果つき)

早見表の内容は、次のスクリプトで実際に出力したものです。そのまま保存して php table.php で実行できます。

検証に使った環境

項目 内容
PHP 8.4.21(CLI)
実行方法 php ファイル名.php
エラー設定 error_reporting(E_ALL)
未実行の範囲 PHP 7.x、PHP 8.0〜8.3、PHP 8.5(この記事の出力はすべて8.4.21のもの)

全パターンを出力するスクリプト

<?php
// table.php : 値ごとの ?? / ?: / isset / empty の結果を一覧にする
error_reporting(E_ALL);
ini_set('display_errors', '0');   // 表を崩さないよう Warning は件数だけ数える

$warnings = 0;
set_error_handler(function (int $no, string $msg) use (&$warnings) {
    $warnings++;
    return true;
});

function show(mixed $v): string
{
    return var_export($v, true) === "array (\n)" ? '[]' : var_export($v, true);
}

$cases = [
    'null'      => null,
    '0'         => 0,
    '"0"'       => '0',
    '""'        => '',
    'false'     => false,
    '[]'        => [],
    '"abc"'     => 'abc',
];

printf("%-8s | %-8s | %-8s | %-6s | %-6s\n", '値', '?? "x"', '?: "x"', 'isset', 'empty');
foreach ($cases as $label => $v) {
    printf("%-8s | %-8s | %-8s | %-6s | %-6s\n",
        $label,
        show($v ?? 'x'),
        show($v ?: 'x'),
        show(isset($v)),
        show(empty($v))
    );
}

// 未定義の変数は配列に入れられないので個別に書く
$before = $warnings;
$r1 = show($undef ?? 'x');
$w1 = $warnings - $before;

$before = $warnings;
$r2 = show($undef ?: 'x');
$w2 = $warnings - $before;

printf("%-8s | %-8s | %-8s | %-6s | %-6s\n", '未定義', $r1, $r2, show(isset($undef)), show(empty($undef)));
echo "Warning の件数  ?? : {$w1} 件 / ?: : {$w2} 件\n";

実行結果(PHP 8.4.21):

値      | ?? "x"   | ?: "x"   | isset  | empty 
null     | 'x'      | 'x'      | false  | true  
0        | 0        | 'x'      | true   | true  
"0"      | '0'      | 'x'      | true   | true  
""       | ''       | 'x'      | true   | true  
false    | false    | 'x'      | true   | true  
[]       | []       | 'x'      | true   | true  
"abc"    | 'abc'    | 'abc'    | true   | false 
未定義 | 'x'      | 'x'      | false  | true  
Warning の件数  ?? : 0 件 / ?: : 1 件

(全角文字の幅の都合で、1行目と「未定義」の行の縦線が少しずれて表示されます。出力はそのまま載せています。)

表から読み取れる3つのこと

気づき 内容
?? と isset は同じ基準 isset がfalseになる行(nullと未定義)だけ、?? は右側の 'x' に切り替わっている
?: と empty は同じ基準 empty がtrueになる行すべてで、?: は右側の 'x' に切り替わっている
未定義で差が出る 結果の値はどちらも 'x' でも、?: はWarningを1件出し、?? は出さない

つまり、$a ?? 'x' は isset($a) ? $a : 'x' と同じ意味、$a ?: 'x' は $a ? $a : 'x' と同じ意味です。empty($a) ? 'x' : $a は結果の値こそ ?: と同じになりますが、empty は未定義でもWarningを出さないという違いがあります。

??の基本の書き方と、issetを使った書き方との比較

書き換えの前と後

?? が入る前は、isset() と三項演算子を組み合わせて書いていました。どちらも同じ結果になります。

<?php
// 従来の書き方
$username = isset($_GET['user']) ? $_GET['user'] : 'nobody';

// ?? を使った書き方(PHP 7.0以降)
$username = $_GET['user'] ?? 'nobody';

$_GET['user'] を1回しか書かなくてよいので、キー名の書き間違いが起きにくく、行も短くなります。

連結して「最初に見つかった値」を使う

?? は続けて書けます。左から順に調べて、最初にnullでも未定義でもなかった値が採用されます。

<?php
$x = null; $y = null; $z = 'z';
echo $x ?? $y ?? $z, "\n";

実行結果:

z

フォームやクエリ文字列で「GETにあればそれ、無ければPOST、それも無ければ既定値」と書きたいときは $_GET['user'] ?? $_POST['user'] ?? 'nobody' のように並べられます。

多次元配列・プロパティでも途中の存在確認が要らない

?? の左側は、途中の階層が無くてもWarningになりません。

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
$user = ['name' => 'sato'];
$obj  = new stdClass();

echo "5: "; echo $undef ?? 'x', "\n";                  // 未定義の変数
echo "6: "; echo $user['age'] ?? 'x', "\n";            // 無いキー
echo "7: "; echo $obj->age ?? 'x', "\n";               // 無いプロパティ
echo "8: "; echo $user['addr']['city'] ?? 'x', "\n";   // 途中の階層も無い

実行結果:

5: x
6: x
7: x
8: x

4行ともWarningは出ていません。isset($user['addr']['city']) が途中のキーの有無を気にせず書けるのと同じ動きです。

??が守ってくれる範囲と、守ってくれない範囲

?? がWarningやErrorを抑えてくれるのは「変数・配列の要素・プロパティの読み取り」です。関数やメソッドの呼び出し、定数の参照は対象外です。次のスクリプトで確かめました。

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');

function f(): ?string { return null; }
echo f() ?? 'default', "\n";            // 戻り値が null → 右側

$s = 'abc';
echo $s[10] ?? 'none', "\n";            // 範囲外の文字位置 → 右側

try { echo APP_ENV ?? 'local', "\n"; }  // 未定義の定数
catch (Error $e) { echo get_class($e), ': ', $e->getMessage(), "\n"; }

try { echo nofunc() ?? 'x'; }           // 未定義の関数
catch (Error $e) { echo get_class($e), ': ', $e->getMessage(), "\n"; }

class P { public string $name; }        // 型付きプロパティ(未初期化)
$p = new P();
echo $p->name ?? '未初期化', "\n";
try { echo $p->name ?: 'x'; }
catch (Error $e) { echo get_class($e), ': ', $e->getMessage(), "\n"; }

実行結果:

default
none
Error: Undefined constant "APP_ENV"
Error: Call to undefined function nofunc()
未初期化
Error: Typed property P::$name must not be accessed before initialization
左側に書いたもの ?? の結果
未定義の変数・配列キー・プロパティ Warningなしで右側
nullを返す関数・メソッドの戻り値 右側
文字列の範囲外の位置($s[10]) Warningなしで右側
初期化前の型付きプロパティ Errorにならず右側(?: だとError)
未定義の定数 Error(defined('APP_ENV') ? APP_ENV : 'local' と書く)
未定義の関数・メソッド Error

定数は ?? では守れないので、defined() で確認する書き方を使います。上のスクリプトと同じ環境で echo defined('APP_ENV') ? APP_ENV : 'local'; を実行すると local と表示されました。

??と?:(エルビス演算子)の違いと使い分け

0や空文字を「値」として扱うかどうかで選ぶ

?: は三項演算子 条件 ? A : B の真ん中を省いた形で、「左がtruthyなら左、そうでなければ右」を返します。クエリ文字列に q=(空文字)と page=0 が届いた想定で比べます。

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
$_GET = ['q' => '', 'page' => '0'];

$q1 = isset($_GET['q']) ? $_GET['q'] : 'all';   // 従来の書き方
$q2 = $_GET['q'] ?? 'all';                      // null合体
$q3 = $_GET['q'] ?: 'all';                      // エルビス
$q4 = !empty($_GET['q']) ? $_GET['q'] : 'all';  // empty で判定
var_dump($q1, $q2, $q3, $q4);

$p1 = $_GET['page'] ?? 1;
$p2 = $_GET['page'] ?: 1;
var_dump($p1, $p2);

実行結果:

string(0) ""
string(0) ""
string(3) "all"
string(3) "all"
string(1) "0"
int(1)
  • ??(と isset の三項)は、空文字や "0" を「届いた値」としてそのまま残します。
  • ?:(と empty の三項)は、空文字や "0" を「値なし」とみなして既定値に置き換えます。

ページ番号や数量のように0が意味を持つ値は ??、表示名のように空文字なら既定の文言にしたい値は ?: と覚えると選びやすくなります。

?:は未定義だとWarningが出る

?: は左側をふつうに評価するので、変数やキーが無いとWarningが出ます。PHP 8.4.21 での文面は次のとおりです(ファイルのパスは環境によって変わるため、/home/taro/app/ に置き換えて表示しています)。

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
$user = ['name' => 'sato'];
$obj  = new stdClass();

echo "1: "; echo $undef ?: 'x', "\n";
echo "2: "; echo $user['age'] ?: 'x', "\n";
echo "3: "; echo $obj->age ?: 'x', "\n";
$n = null;
echo "4: "; echo $n['age'] ?: 'x', "\n";

実行結果:

1: 
Warning: Undefined variable $undef in /home/taro/app/warn.php on line 7
x
2: 
Warning: Undefined array key "age" in /home/taro/app/warn.php on line 8
x
3: 
Warning: Undefined property: stdClass::$age in /home/taro/app/warn.php on line 9
x
4: 
Warning: Trying to access array offset on null in /home/taro/app/warn.php on line 11
x
左側 PHP 8.4 のメッセージ
未定義の変数 Warning: Undefined variable $undef
無い配列キー Warning: Undefined array key "age"
無いプロパティ Warning: Undefined property: stdClass::$age
nullに対する配列アクセス Warning: Trying to access array offset on null

同じ4つを ?? に置き換えると、前の章で見たとおりWarningは1件も出ません。

両方を組み合わせる:未定義でも安全で、空文字も既定値にする

「キーが無くてもWarningを出したくない」かつ「空文字なら既定値にしたい」という場面では、?? で受けてから ?: を当てます。

<?php
$_GET = ['q' => '', 'page' => '0'];
$q5 = ($_GET['q'] ?? '') ?: 'all';       // キーはあるが空文字
$q6 = ($_GET['nokey'] ?? '') ?: 'all';   // キー自体が無い
var_dump($q5, $q6);

実行結果:

string(3) "all"
string(3) "all"

内側の ?? でWarningを防ぎ、外側の ?: で空の値を置き換えるという2段構えです。括弧を付けておくと、読む人にも順番が伝わります。

文字列連結や足し算と組み合わせるときの優先順位

?? を使い始めて最初につまずきやすいのが、演算子の優先順位です。?? は、文字列連結の .、足し算の +、比較の === よりも優先順位が低いため、括弧なしで混ぜると思った順番で評価されません。PHPマニュアルの「演算子の優先順位」のページにも、文字列連結と ?? を並べたときの注意が例つきで載っています(今回ページを開いて確認しました)。

"文字列" . $a ?? "x" が既定値にならない例

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');

$a = null;
echo "A: ", "名前: " . $a ?? "未設定", "\n";      // 直す前
echo "B: ", "名前: " . ($a ?? "未設定"), "\n";    // 直した後

$user = [];
echo "C: ", "名前: " . $user['name'] ?? "未設定", "\n";    // 直す前
echo "D: ", "名前: " . ($user['name'] ?? "未設定"), "\n";  // 直した後

実行結果:

A: 名前: 
B: 名前: 未設定
C: 
Warning: Undefined array key "name" in /home/taro/app/prec.php on line 10
名前: 
D: 名前: 未設定

Aの行は、PHPが次のように解釈しています。

<?php
// 書いたもの
"名前: " . $a ?? "未設定";
// PHPの解釈(. が先に結び付く)
("名前: " . $a) ?? "未設定";

"名前: " . $a は "名前: " という文字列になり、nullではないので右側の "未設定" は使われません。Cの行ではさらに、$user['name'] が ?? の直接の左側ではなく連結の中に入ってしまうため、?? の「Warningを出さない」効果も届かなくなり、Warningが表示されています。

足し算・比較・キャストでも同じことが起きる

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');

$price = 500;
echo "G: "; var_dump($price ?? 0 + 100);       // $price ?? (0 + 100)
echo "H: "; var_dump(($price ?? 0) + 100);

$flag = 'on';
echo "K: "; var_dump($flag ?? 'none' === 'none');   // $flag ?? ('none' === 'none')

echo "N: "; var_dump((int) $_GET['page'] ?? 1);     // ((int) $_GET['page']) ?? 1
echo "O: "; var_dump((int) ($_GET['page'] ?? 1));

実行結果:

G: int(500)
H: int(600)
K: string(2) "on"
N: 
Warning: Undefined array key "page" in /home/taro/app/prec2.php on line 12
int(0)
O: int(1)
書いた式 PHPの解釈 結果 括弧を付けた形
"名前: " . $a ?? "未設定" ("名前: " . $a) ?? "未設定" 既定値にならない "名前: " . ($a ?? "未設定")
$price ?? 0 + 100 $price ?? (0 + 100) 500のまま(100が足されない) ($price ?? 0) + 100
$flag ?? 'none' === 'none' $flag ?? ('none' === 'none') 真偽値ではなく "on" ($flag ?? 'none') === 'none'
(int) $_GET['page'] ?? 1 ((int) $_GET['page']) ?? 1 Warningが出て0 (int) ($_GET['page'] ?? 1)

?? をほかの演算子と同じ式に書くときは、?? の部分を括弧で囲む。 これだけ覚えておけば、優先順位の表を暗記しなくても安全に書けます。

優先順位の位置関係(抜粋)

優先順位 演算子
高い !、キャスト (int) など
↑ + -(PHP 8.0以降は . より高い)
↑ .(文字列連結)
↑ < > == === などの比較
↑ &&、その次に論理和の記号
↑ ??(右結合)
↑ ? :(三項演算子。PHP 8.0以降は入れ子に括弧が必要)
低い = ??= などの代入

?? は右結合なので、$x ?? $y ?? $z は $x ?? ($y ?? $z) と解釈されます。左から順に調べるという結果は変わりません。

??=(null合体代入演算子)で初期化する

??= は、左側が未定義またはnullのときだけ右側を代入する演算子です(PHP 7.4以降)。$a ??= 1; は $a = $a ?? 1; とほぼ同じ意味で、if (!isset($a)) { $a = 1; } を1行にしたものです。

設定配列に既定値を入れる

<?php
$opt = ['limit' => 0, 'sort' => null];
$opt['limit'] ??= 20;   // 0 は null ではないので上書きされない
$opt['sort']  ??= 'id'; // null なので代入される
$opt['page']  ??= 1;    // キーが無いので代入される
var_export($opt);

実行結果:

array (
  'limit' => 0,
  'sort' => 'id',
  'page' => 1,
)

limit の0が残っている点に注目してください。0や空文字を「設定済み」として尊重したいとき、??= はちょうどよい基準になります。

カウンターの初期化

配列で出現回数を数えるとき、最初の1回目だけ0を入れる処理がすっきり書けます。

<?php
$count = [];
foreach (['php', 'go', 'php', 'php'] as $w) {
    $count[$w] ??= 0;
    $count[$w]++;
}
var_export($count);

実行結果:

array (
  'php' => 3,
  'go' => 1,
)

右側は必要なときだけ実行される

??= の右側は、代入が必要なときにだけ評価されます。時間のかかる処理を右側に置いても、値がすでにあれば呼ばれません。

<?php
function heavy(): string { echo "(heavy を実行)\n"; return 'computed'; }

$cache = ['k' => 'cached'];
$cache['k'] ??= heavy();   // すでに値がある → heavy() は呼ばれない
echo $cache['k'], "\n";
$cache['m'] ??= heavy();   // キーが無い → heavy() が呼ばれる
echo $cache['m'], "\n";

実行結果:

cached
(heavy を実行)
computed

この性質を使うと、「最初に呼ばれたときだけ読み込み、2回目以降は保持した値を返す」処理を1行で書けます。

<?php
class Repo {
    private ?array $rows = null;

    public function rows(): array {
        return $this->rows ??= $this->load();
    }

    private function load(): array {
        echo "(load を実行)\n";
        return [1, 2, 3];
    }
}

$r = new Repo();
echo count($r->rows()), "\n";
echo count($r->rows()), "\n";

実行結果:

(load を実行)
3
3

load() は1回しか実行されていません。なお、読み込んだ結果がnullになり得る処理では、毎回 load() が呼ばれることになります(nullは「未設定」と同じ扱いのため)。その場合は「読み込み済みかどうか」を別の真偽値で持つと確実です。

深い階層にもまとめて代入できる

<?php
$tree = [];
$tree['a']['b']['c'] ??= 'deep';
echo json_encode($tree), "\n";

実行結果:

{"a":{"b":{"c":"deep"}}}

途中の配列が無くても、Warningなしで必要な階層が作られます。

?->(nullsafe演算子)と??を連結する

?-> は、左側のオブジェクトがnullなら、その先のプロパティ参照やメソッド呼び出しをやめてnullを返す演算子です(PHP 8.0以降)。?? と組み合わせると、「途中のどこかがnullなら既定値」という処理が1行で書けます。

4パターンで動きを確かめる

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');

class Address {
    public function __construct(public ?string $city = null) {}
    public function label(): string {
        echo "(label を実行)\n";
        return "市: {$this->city}";
    }
}
class User {
    public function __construct(public string $name, public ?Address $address = null) {}
    public function getAddress(): ?Address { return $this->address; }
}

$u1 = new User('sato', new Address('Osaka'));  // すべて揃っている
$u2 = new User('kato');                        // address が null
$u3 = null;                                    // ユーザー自体が null
$u4 = new User('abe', new Address(null));      // city が null

foreach (['u1' => $u1, 'u2' => $u2, 'u3' => $u3, 'u4' => $u4] as $k => $u) {
    echo $k, ': ', $u?->address?->city ?? '未登録', "\n";
}

実行結果:

u1: Osaka
u2: 未登録
u3: 未登録
u4: 未登録

$u?->address?->city は、途中でnullに出会った時点で式全体がnullになり、最後の ?? '未登録' が既定値に置き換えます。

プロパティだけなら??でも書ける。メソッド呼び出しには?->が必要

プロパティをたどるだけなら、?? だけでも同じ結果になります。違いが出るのはメソッド呼び出しです。

<?php
// (上のクラス定義と $u2 に続けて実行)
echo "--- ?? だけで書く\n";
echo $u2->address->city ?? '未登録', "\n";

echo "--- メソッド呼び出しは ?? だけでは守れない\n";
try {
    echo $u2->getAddress()->label() ?? '未登録', "\n";
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
echo $u2->getAddress()?->label() ?? '未登録', "\n";

実行結果:

--- ?? だけで書く
未登録
--- メソッド呼び出しは ?? だけでは守れない
Error: Call to a member function label() on null
未登録

?? が見逃してくれるのは「読み取り」だけで、nullに対するメソッド呼び出しはErrorで止まります。 メソッドを挟むチェーンでは ?-> を使い、最後に ?? で既定値を付ける形が定番です。

打ち切られた先の引数は評価されない

?-> で打ち切られると、その先のメソッドも引数も実行されません。

<?php
// (上のクラス定義と $u2, $u3 に続けて実行)
var_dump($u3?->name);
var_dump($u2?->getAddress()?->label());   // label() は呼ばれない
function arg(): string { echo "(arg を実行)\n"; return 'x'; }
var_dump($u3?->foo(arg()));               // arg() も呼ばれない

実行結果:

NULL
NULL
NULL

「(label を実行)」「(arg を実行)」が1度も表示されていないことから、打ち切りが確認できます。

?->が対象にするのはnullだけ

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
var_dump($nothing?->name);   // 未定義の変数

実行結果:

Warning: Undefined variable $nothing in /home/taro/app/nullsafe.php on line 4
NULL
比べる点 ?? ?->
未定義の変数 Warningなし Warningあり
nullのプロパティ参照 右側に切り替え nullを返す
nullに対するメソッド呼び出し Errorで止まる nullを返す(呼び出さない)
既定値の指定 できる できない(?? と組み合わせる)
代入の左側($a?->b = 1) 対象外 書けない
配列($arr['k']) 使える 配列には使わない(オブジェクト用)

変数自体が未定義かもしれない場面は多くありませんが、その場合は ($nothing ?? null)?->name のように ?? で受けてから ?-> をつなげます。

演算子ごとの対応PHPバージョンとWarningの出方

対応バージョン表

機能 書き方 導入バージョン 確認方法
エルビス演算子 $a ?: 'x' PHP 5.3 一般に知られた仕様(今回は未確認・未実行)
null合体演算子 $a ?? 'x' PHP 7.0 PHPマニュアルの7.0移行ガイドを開いて確認、8.4.21で実行
null合体代入演算子 $a ??= 'x' PHP 7.4 PHPマニュアルの7.4移行ガイドを開いて確認、8.4.21で実行
nullsafe演算子 $a?->b PHP 8.0 PHPマニュアルの8.0移行ガイドを開いて確認、8.4.21で実行
throw式(?? の右側にthrow) $a ?? throw new E() PHP 8.0 同上、8.4.21で実行

PHP 7.0〜7.3の環境では ??= が、PHP 7.x の環境では ?-> が構文エラーになります。動かす環境のバージョンは php -v で確認できます。

PHP 8.0でNoticeからWarningに変わった

PHPマニュアルの「PHP 8.0 下位互換性のない変更点」には、未定義の変数・未定義の配列キー・未定義のプロパティの読み取りなどが、NoticeからWarningへ引き上げられたことが記載されています(今回ページを開いて確認しました)。

状況 PHP 7.x PHP 8.0以降(8.4.21で実行して確認)
未定義の変数 Notice Warning: Undefined variable $undef
無い配列キー Notice Warning: Undefined array key "age"
無いプロパティ Notice Warning: Undefined property: stdClass::$age
nullへの配列アクセス Notice(7.4) Warning: Trying to access array offset on null
nullへのメソッド呼び出し Error Error: Call to a member function label() on null

PHP 7.x 側の列は移行ガイドの記載にもとづくもので、7.x での実行は今回行っていません(未実行)。PHP 7から8へ上げたあとに画面やログへWarningが増えた場合、該当の行を ?? に書き換えるのがいちばん手早い直し方です。

??の右側で例外を投げる(PHP 8.0以降)

PHP 8.0で throw が式として書けるようになり、?? の右側に置けます。「必須の値が無ければその場で止める」処理が1行になります。

<?php
$_GET = [];
try {
    $id = $_GET['id'] ?? throw new InvalidArgumentException('id は必須です');
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}

実行結果:

id は必須です

どれを使うかの判断表

やりたいこと 選ぶ書き方 例
キーが無いかnullなら既定値。0や空文字は残す ?? $page = $_GET['page'] ?? 1;
空文字・0・falseも既定値に置き換える(変数は必ず定義済み) ?: $label = $name ?: '名無し';
キーが無いかもしれず、空文字も既定値にしたい ?? と ?: $q = ($_GET['q'] ?? '') ?: 'all';
値が無いときだけ代入したい ??= $opt['limit'] ??= 20;
一度だけ計算して保持したい ??= return $this->rows ??= $this->load();
オブジェクトがnullかもしれないままメソッドをつなぐ ?-> と ?? $u?->getAddress()?->label() ?? '未登録'
必須の値が無ければ止めたい ?? throw $id = $_GET['id'] ?? throw new E();
「存在するか」だけを真偽値で知りたい isset() if (isset($_POST['agree'])) { ... }
nullとキーなしを区別したい array_key_exists() array_key_exists('memo', $row)

判断の流れを図にすると次のようになります。

flowchart TD
    A[既定値に切り替えたい] --> B{0や空文字も<br>既定値にしたいか}
    B -- いいえ --> C{代入先と判定対象が同じか}
    C -- はい --> D["??= を使う"]
    C -- いいえ --> E["?? を使う"]
    B -- はい --> F{変数やキーが<br>未定義の可能性があるか}
    F -- いいえ --> G["?: を使う"]
    F -- はい --> H["(?? '') ?: 既定値 と書く"]
    A2[nullかもしれないオブジェクトの<br>メソッドを呼びたい] --> I["?-> でつなぎ、最後に ?? を付ける"]

つまずきやすい点のチェックリスト

チェック項目 起きること こう書くとよい
?? を . や + と同じ式に括弧なしで書いている 既定値にならない、Warningが出る ?? の部分を括弧で囲む
0や空文字を既定値にしたいのに ?? を使っている 0や空文字がそのまま残る ?: か (?? '') ?: 既定値
0を正しい値として残したいのに ?: を使っている 0が既定値に置き換わる ?? に替える
?? でメソッド呼び出しのnullを守ろうとしている Call to a member function ... on null ?-> を使う
?? で未定義の定数を守ろうとしている Undefined constant のError defined() で確認する
??= で保持する値がnullになり得る 毎回右側が実行される 読み込み済みフラグを別に持つ
PHP 7.3以前で ??= を書いている 構文エラー $a = $a ?? 値; と書く
isset($a) ?? 'x' と書いている isset の戻り値は常に真偽値なので右側は使われない $a ?? 'x' と書く

よくある質問

Q. ?? と isset() はどちらを使えばよいですか? A. 「値を取り出して、無ければ既定値にする」なら ??、「あるかどうかで処理を分ける」なら isset() が向いています。判定の基準はどちらも同じ(未定義またはnullかどうか)で、この記事の表でも isset がfalseの行だけ ?? が右側に切り替わっていました。

Q. $a ?? 'x' と $a ?: 'x' はどう違いますか? A. ?? はnullと未定義だけを右側に切り替え、?: は0、"0"、空文字、false、空配列も右側に切り替えます。また、未定義の変数やキーに対して ?: はWarningを出しますが、?? は出しません。0や空文字に意味がある値は ?? を選ぶと安心です。

Q. "合計: " . $total ?? 0 が思ったとおりに動きません。 A. 文字列連結の . のほうが ?? より優先順位が高く、("合計: " . $total) ?? 0 と解釈されるためです。"合計: " . ($total ?? 0) のように ?? の部分を括弧で囲むと、意図どおり既定値が使われます。

Q. ?-> があれば ?? は要らなくなりますか? A. 役割が違うので両方使います。?-> はnullのときにチェーンを打ち切ってnullを返すだけで、既定値は指定できません。$user?->address?->city ?? '未登録' のように、最後に ?? を付けて既定値を決めるのが基本の形です。

まとめ

  • ?? は「未定義またはnull」のときだけ右側を返す。 判定基準は isset() と同じで、Warningも出ない。
  • ?: は「falsy」のとき右側を返す。 0・"0"・空文字・false・空配列も置き換わり、未定義ではWarningが出る。
  • 0や空文字を残したいなら ??、置き換えたいなら ?:。 両方の性質が欲しいときは ($x ?? '') ?: 既定値。
  • ?? は . + === より優先順位が低い。 ほかの演算子と混ぜるときは ?? の部分を括弧で囲む。
  • ??= は値が無いときだけ代入する。 右側は必要なときだけ実行されるので、初期化や一度きりの読み込みに向く。
  • ?-> はnullならチェーンを打ち切る。 メソッド呼び出しを含むときは ?-> でつなぎ、最後に ?? で既定値を付ける。
  • 対応バージョンは ?? が7.0、??= が7.4、?-> が8.0。 PHP 8.0以降、未定義の変数やキーの読み取りはWarningになる。

参考・出典

  • PHPマニュアル「演算子の優先順位」 https://www.php.net/manual/ja/language.operators.precedence.php
  • PHPマニュアル「PHP 7.0 新機能」 https://www.php.net/manual/ja/migration70.new-features.php
  • PHPマニュアル「PHP 7.4 新機能」 https://www.php.net/manual/ja/migration74.new-features.php
  • PHPマニュアル「PHP 8.0 新機能」 https://www.php.net/manual/ja/migration80.new-features.php
  • PHPマニュアル「PHP 8.0 下位互換性のない変更点」 https://www.php.net/manual/ja/migration80.incompatible.php

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?