17
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【PHP8.4】プロパティに非対称な可視性を設定できるようになる

Last updated at Posted at 2024-10-07

PHP8.4で新潟アクセス修飾子が正式採用されました。

なにそれ?

class Foo
{
    public private(set) string $bar = 'baz';
}

$foo = new Foo();

echo $foo->bar;    // OK
$foo->bar = 'qux'; // エラー

読み込みはpublic、書き込みはprivateなプロパティ、通称新潟アクセス修飾子です。
新潟という名前は大昔にネタでなされた命名で、『読めるけど書けない』の例として槍玉に挙げられたというわけです。

PHP8.1で似たようなreadonlyという修飾子が導入されたのですが、これは『最初の一回だけ書き込めるけど、一度書き込むと二度と上書きできない』というものであり、実は新潟アクセス修飾子の定義とは微妙に似て非なるものでした。
今回の非対称可視性が、本来の意味での新潟アクセス修飾子ということになります。

以下は該当のRFC、Asymmetric Visibility v2の紹介です。

PHP RFC: Asymmetric Visibility v2

Introduction

PHPにはプロパティの可視性を制御する修飾子は昔からありましたが、その制御はgetとsetの両方で常に同じであり、すなわち対称でした。
このRFCでは、getとsetそれぞれに異なる制御を持つ、非対称の可視性を導入することを提案します。
本RFCの構文は主にSwiftから採用しています。

Proposal

set操作の可視性を宣言する、新たな構文を提供します。
具体的には以下のとおりです。

class Foo
{
    public private(set) string $bar = 'baz';
}

$foo = new Foo();
var_dump($foo->bar); // "baz"
$foo->bar = 'beep';  // Visibility error

このプロパティ$barは、読み込みの可視性はpublicになりますが、書き込みはprivateのスコープからしか行うことができません。

可視性の制限以外に、プロパティの動作への変更はありません。

非対称可視性はコンストラクタプロパティプロモーションでも使用可能です。

class Foo
{
    public function __construct(
        public private(set) string $bar,
    ) {}
}

Abbreviated form

最も一般的な利用法は、読み込みはpublicに可能だが、書き込みはprivateからしかできないプロパティになるでしょう。
PHPのプロパティは、可視性が未指定の場合は暗黙的にpublicとなります。
そのため、public get可視性を省略してset可視性だけを設定することができます。

public private(set) string $foo;
private(set) string $foo;

public protected(set) string $foo;
protected(set) string $foo;

可視性を制限された構造体は、以下のように作ることができます。

class Book
{
    public function __construct(
        private(set) string $title,
        private(set) Author $author,
        private(set) int $pubYear,
    ) {}
}

これはreadonlyクラスと似ていますが、クラス内メソッドからのプロパティ書き替えは何度でも可能です。

References

リファレンスは、書き込みを許可するスコープからのみ取得可能です。
要するにget可視性ではなくset可視性に従います。
許可されていないリファレンスを取得しようとするとエラーが発生します。

class Foo
{
    public protected(set) int $bar = 0;
 
    public function test() {
        $bar = &$this->bar; // setできるのでOK
        $bar++;
    }
}
 
class Baz extends Foo
{
    public function stuff() {
        $bar = &$this->bar; // setできるのでOK
        $bar++;
    }
}

$foo = new Foo();
$foo->test(); // メソッド呼んでるだけなのでOK

$baz = new Baz();
$baz->stuff(); // メソッド呼んでるだけなのでOK

$bar = &$foo->bar; // setできないのでエラー

Array properties

可視性のない配列プロパティへの書き込みは許可されません。
技術的には、最初に配列へのリファレンスを取得することになるため、set可視性に従います。

class Test
{
    public function __construct(public private(set) array $arr = []) {}
 
    public function addItem(string $v): void
    {
        $this->arr[] = $v; // setできるのでOK
    }
}
 
$t = new Test();
 
$t->addItem('beep'); // メソッド呼んでるだけなのでOK
var_dump($t->arr);   // getできるのでOK
$t->arr[] = 'boop';  // エラー

Object properties

オブジェクトプロパティの場合、可視性はオブジェクトそのものの変更にのみ適用されます。
オブジェクト内部までは影響しません。
これはreadonlyの動作と一致します。

class Bar{
    public string $name = 'beep';
}

class Foo{
    public private(set) Bar $bar;
}

$f = new Foo();
$f->bar->name = 'boop'; // OK
$f->bar = new Bar();    // エラー

Permitted visibility

set可視性は、get可視性と同じかそれより狭い範囲でなければなりません。
つまり、protected public(set) string $fooは許可されません。

同じset可視性とget可視性を明示することは、許可されますが冗長です。
ただし、後述しますが読み取り専用プロパティでは必要になる場合があります。

Inheritance

PHPでは、子クラスで親クラスのプロパティをオーバーライドできます。
ただし、型が同じであり、可視性が同じか広がる場合に限ります。
つまり、protectedpublicでオーバーライドすることはできますが、privateでのオーバーライドはできません。

このRFCでもそのルールを継承しますが、ひとつ注意点があります。
privateフィールドは子クラスでシャドウ化されます。
つまり、privateフィールドはオーバーライドされず、子クラスでは完全に異なるプロパティになります。
またprivateフィールドは、他のユーザに上書きされたくないという意図を開発者が示していると考えるべきでしょう。

そのため、private(set)フィールドは暗黙的にfinalとして扱い、オーバーライドできないこととします。

class A {
    private string $foo;
}
class B extends A {
    public private(set) string $foo; // 暗黙的にfinal
}
 
class C extends A {
    public protected(set) string $foo;
}
 
class D extends C {
   public string $foo;
}
 
class E extends B {
    public protected(set) $foo; // エラー
}

B::$fooによってA::$fooはシャドウ化され、B::$fooとは全く無関係なプロパティになります。
これは既存PHPの動作です。

同様にC::$fooによってA::$fooがシャドウ化されます。
D::$fooC::$fooの可視性を広げる操作であり、許可されます。

しかし、Bの子クラスで$fooをオーバーライドすることはできません。

class A {
    public string $foo;
}
 
class B extends A {
    public protected(set) string $foo; // エラー
}

可視性を狭めることはできません。
これは既存PHPの動作です。

ただし、可視性が書かれていない場合はprivateより狭いものとして扱われます。
つまり、以下は許可されます。

class P {
   public $answer { get => 42; }
}
class C extends P {
   public protected(set) $answer { get => 42; set => print "Why?"; }
}

Interaction with property hooks

プロパティのsetおよびgetに介入するプロパティフックは、プロパティにアクセス可能な場合のみその動作に影響を与えます。
非対称可視性では逆に、プロパティにアクセス可能な場合にはその動作に影響を与えません。

つまり、プロパティフックと非対称可視性は全く相互作用せず、互いに独立した機能です。

ひとつだけ注意点があり、setフックが定義されていないときに可視性を設定することは無意味であり、この場合はコンパイルエラーが発生します。

// コンパイルエラーになる
class Universe
{
    public private(set) $answer { get => 42; }
}

Interaction with interface properties

プロパティフックのRFCでは、interfaceおよびabstractクラスに対してgetのみのフックを与える宣言も許可されます。

従って、次の例は完全に合法です。

interface Named
{
    public string $name { get; }
}
 
class ExampleA implements Named
{
    public protected(set) string $name;
}
 
class ExampleB implements Named
{
    public string $name { get => 'Larry'; }
}
 
class ExampleC implements Named
{
    public string $name;
}
 
class ExampleD implements Named
{
    public readonly string $name;
}

いずれの例でもExampleX::$nameはpublicに読み込み可能であるため、interfaceの要件が満たされます。

ただし、interfaceにおいてpublicなsetを明記している場合は、子クラスで狭めることはできません。

interface Aged
{
    public string $age { get; set; }
}
 
class ExampleE implements Aged
{
    public protected(set) $age; // エラー
}

Relationship with readonly

readonlyフラグは、protected(set)かつ一回だけ書き込み可能なプロパティとして扱われます。
明示的にprivate(set)と指定した場合は、前述のとおり暗黙的にfinalとして扱われます。

// 読み込みpublic、書き込みprotected、一回だけ書き込み可能。
public protected(set) readonly string $foo;
public readonly string $foo;
readonly string $foo;

// 読み込みpublic、書き込みprivate、一回だけ書き込み可能。暗黙的なfinal。
public private(set) readonly string $foo;
private(set) readonly string $foo;

// 読み込みpublic、書き込みpublic、一回だけ書き込み可能。
public public(set) readonly string $foo;
public(set) readonly string $foo;

// 読み込みprivate、書き込みprivate、一回だけ書き込み可能。暗黙的なfinal。
private private(set) readonly string $foo;
private readonly string $foo;

// 読み込みprotected、書き込みprotected、一回だけ書き込み可能。
protected protected(set) readonly string $foo;
protected readonly string $foo;

// エラー。 set>getにはできない。
protected public(set) readonly string $foo;

単純にprivateだけ指定した場合は、setもprivate(set)となり、従って暗黙的なfinalとなります。

Interaction with __set and __unset

現在のPHPの__setは、かなり動作に一貫性がありません。
プロパティアクセス時、__setは以下の場合に呼び出されます。

・アクセスされたプロパティが存在しない。
・アクセスされたプロパティの可視性が、呼び出し元より狭い。
・アクセスされたプロパティが事前にunset()された。

非対称可視性は2番目のケースに影響を与えます。
プロパティを見ることはできるが書くことはできない場合があるからです。

この場合の動作について、現在のロジックを維持することを選択しました。
すなわち、__setの呼び出しに影響を与えるのはget可視性だけです。
public protected(set)プロパティにpublicから書き込もうとした場合、__set()は呼び出されず、単にエラーになります。
protected private(set)プロパティについては、現在の動作と全く差はありません。

このアプローチは現在の動作への影響を最も少なくするもので、既存コードへのBCブレークはありません。
__setの呼び出しをset可視性に依存するように変更することは適切ではないと考えていますが、将来的に変更の余地はあります。

Typed properties

非対称可視性は、明示的に型が指定されているプロパティにのみ設定可能です。
ただし、プロパティはmixed型かつデフォルト値nullを設定可能であるため、大きな制限ではありません。

Static properties

非対称可視性は、オブジェクトプロパティにのみ設定可能であり、静的プロパティには対応しません。
様々な理由により実装が困難であり、そして特に役立つ場面もありません。

Reflection

ReflectionPropertyクラスにisProtectedSet()isPrivateSet()のふたつのメソッドが追加されます。
どんなメソッドなのかは名前のとおりです。

class Test
{
    public string $open;
    public protected(set) string $restricted;
}
$rClass = new ReflectionClass(Test::class);

$rOpen = $rClass->getProperty('open');
print $rOpen->isProtectedSet() ? 'Yep' : 'Nope'; // Nope
 
$rRestricted = $rClass->getProperty('restricted');
print $rRestricted->isProtectedSet() ? 'Yep' : 'Nope'; // Yep

また2定数ReflectionProperty::IS_PROTECTED_SETReflectionProperty::IS_PRIVATE_SETも追加されます。
他の可視性修飾子と同様、ReflectionProperty::getModifiers()が返してきます。

ReflectionProperty::setValue()を使ったスコープ外からのプロパティ書き替えは、これまでどおり許可されます。

Syntax discussion

非対称可視性はSwift・C#・Kotlinなど幾つかの言語に存在しますが、構文はそれぞれ異なります。
大きく分けるとプレフィックス形式とフック形式の2種類になります。

// Prefix-style:
class A
{
    public private(set) string $name;
}
 
// Hook-embedded-style:
class A
{
    public string $name { private set; }
}

PHPにおいては、プレフィックス形式の書式が適切であると判断しました。

Prefix-style is more visually scannable

プレフィックス形式は、プロパティ定義を左から右に読んでいくだけで全ての視覚性オプションを一覧することができます。
ユーザが$に辿り着いたときには全ての可視性を把握できています。

フック形式では、$までに全ての可視性がわかる場合とわからない場合があります。
また悪いことに、プロパティフックと組み合わせると定義部分がさらに離れてしまいます。

// プレフィックス形式
class PrefixStyle
{
    // 最初だけで全てわかる
    public private(set) string $phone {
        get {
            if (!$this->phone) {
                return '';
            }
            if ($this->phone[0] === 1) {
                return 'US ' . $this->phone;
            }
            return 'Intl +' . $this->phone;
        }
 
       set {
            $this->phone = implode('', array_filter(fn($c) => is_numeric($c), explode($value)))
        }
    }
}

// フック形式
class HookEmbeddedStyle
{
    public string $phone {
        get {
            if (!$this->phone) {
                return '';
            }
            if ($this->phone[0] === 1) {
                return 'US ' . $this->phone;
            }
            return 'Intl +' . $this->phone;
        }
        // ここまで読まないとわからない
        private set {
            $this->phone = implode('', array_filter(fn($c) => is_numeric($c), explode($value)))
        }
    }
}

Prefix-style is shorter

枝葉末節ですが、プレフィックス形式の方がやや短くなります。

public private(set) string $name;
public string $name { private set; }

プレフィックス形式だけで可能な省略構文ではより顕著になります。

private(set) string $name;
public string $name { private set; }
var string $name { private set; }

Prefix-style doesn't presume a connection with hooks

前述のとおり、可視性はプロパティフックと独立しており、両方が実装されていても相互作用することはありません。
可視性をフック形式で書くと誤解を招く可能性があります。

Use cases and examples

readonlyとプロパティフックにより、PHPは既にプロパティを高度に利用する方法が幾つも存在します。
しかし、あらゆる状態に対応するためには漏れがあり、本RFCはその穴埋めを目指しています。

Readonly is limited

readonlyは、予期せず変更されないことが保証されたpublicプロパティをPHPにもたらし、これによって多くの定型文や不必要なコードが排除されました。
ただreadonlyは少々行きすぎなところがあり、プロパティが『予期せず』変更されることを防ぐだけではなく『一切』変更されないようにします。
不変なオブジェクトはもちろん有用ですが、常にreadonlyがふさわしいとは限りません。
読み取りは制限せず、書き込みは制限するプロパティが望ましいことも多々あります。

class Record
{
    private bool $dirty = false;
 
    private array $data = [];
 
    public function set($key, $val): void
    {
        $this->data[$key] = $val;
        $this->dirty = true;
    }
 
    public function isDirty(): bool
    {
        return $this->dirty;
    }
 
    public function save(): void
    {
        if ($this->dirty) {
            /* 保存処理、省略 */

            $this->dirty = false;
        }
    }
}

この例では、オブジェクトのダーティステータスを表す$dirtyは素直に読み取り可能にしたいでしょう。
しかし、publicやreadonlyではこれを実行できません。
publicではいつでもどこでも変更できるようになってしまいますし、readonlyでは保存後にdirtyを解消することができません。

これは非対称可視性を用いることで容易に実装可能です。

class Record
{
    public private(set) bool $dirty = false;
 
    private array $data = [];
 
    public function set($key, $val): void
    {
        $this->data[$key] = $val;
        $this->dirty = true;
    }
 
    public function save(): void
    {
        if ($this->dirty) {
            /* 保存処理、省略 */

            $this->dirty = false;
        }
    }
}

無駄な定型文を必要とせず、想定外に書き替えられる心配もない、privateからのみ変更可能なpublicプロパティが実装できました。

Hooks can be verbose

プロパティフックはできるだけコンパクトに記述できるように設計されていますが、それでも扱いにくいユースケースは存在します。
たとえば非対称可視性は以下で概ね再現できます。

class NamedThing
{
    private string $_name;
 
    public string $name { get => $this->_name; }
 
    public function __construct(string $name)
    {
        $this->_name = $name;
    }
}

しかし、何をやっているかわかりにくいうえ、パフォーマンスにも若干の影響があります。
非対称可視性のほうがずっと簡単です。

class NamedThing
{
    public function __construct(public private(set) string $name) {}
}

Backward Incompatible Changes

下位互換性のない変更はありません。
PHP8.3以前で非対称可視性構文を用いるとエラーになります。

Proposed PHP Version(s)

PHP 8.4

Future Scope

この項目は将来の展望であり、本RFCには含まれません。

Alternate operations

現時点では、非対称可視性のスコープは読み書きだけです。
今後、以下のような可視性を追加することも考えられるかもしれません。

protected(&get)
値を取得できるかとは独立して、値へのリファレンスを取得できるか。

private(setref)
特定のスコープからは、リファレンスを通して値を書き込める。

Additional visibility

PHP自体が新たな可視性を採用した場合、非対称可視性はその可視性と完全な互換性があります。
たとえばパッケージレベル可視性public package(set)は自然な構文です。

Proposed Voting Choices

本RFCは賛成24反対7の賛成多数で可決されました。

感想

最初のネタアイデアniigataから14年経ってPHP本体に取り込まれるとはすばらしいですね。
もちろん直接関係したわけではないので、ただの偶然ですが。

ということで今後はきっとpublic private(set)がメインの可視性になっていくことでしょう。
この可視性はたいへん便利だと思うのですが、他言語にもそんなに多くは見当たらないのが不思議ですね。

さて、かつては碌に型もないと言われていたPHPですが、今やreadonlyプロパティフック、さらにUNION型交差型そして型合成までなんでもござれです。
さすがに今どきvar $property;みたいなフリーダム運用は絶望しかないのでやめるべきですが、かといって全てを厳密に縛りすぎるとそれはそれで逆に面倒なので、程々に型を縛って程々に安定したプログラミングをしていきましょう。

17
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?