مقاله تخصصی باورژن

بهبود ارگونومی کار با آبجکت‌ها در PHP

با راهکارهای بهبود کار با آبجکت‌ها در PHP آشنا شوید، از کاهش کدهای تکراری و Constructor Promotion تا Named Parameters، Immutability و مدیریت پراپرتی‌ها.

تصویر بهبود کار با آبجکت‌ها در PHP با تمرکز بر Constructor Promotion، Named Parameters، مدیریت دسترسی پراپرتی‌ها و کاهش کدهای تکراری.
این مقاله را با تمرکز بر مفاهیم و مثال‌های فنی بخوانید. زمان مطالعه تقریبی: 24 دقیقه

در این مقاله، منظور از Object Ergonomics این است که کار با آبجکت‌ها در PHP از نظر نوشتن کد، خوانایی، نگهداری و توسعه چقدر ساده و روان باشد. تمرکز مقاله بر این است که چگونه می‌توان با تغییرات در سینتکس زبان، کدهای تکراری را کاهش داد و در عین حال Type Safety، قابلیت تغییرناپذیری (Immutability) و انعطاف‌پذیری آبجکت‌ها را حفظ کرد.

در ماه‌های اخیر، چندین RFC و بحث مختلف مطرح شده‌اند که همگی به یک حوزه مسئله بسیار مشابه، یا بهتر بگوییم چند حوزه مسئله مرتبط با یکدیگر، می‌پردازند. بسیاری از این پیشنهادها به دلایل مختلف نتوانسته‌اند حمایت کافی به دست آورند. یکی از مهم‌ترین دلایل این است که حوزه‌های مسئله آن‌ها با یکدیگر هم‌پوشانی دارند، اما راهکارهای جداگانه و تکه‌تکه معمولا یک مشکل را به قیمت بدتر شدن مشکل دیگر حل می‌کنند یا برای حل سایر مشکلات کافی نیستند.

برای حل این مسائل، می‌خواهم یک قدم به عقب برگردم و این حوزه‌های مرتبط را در کنار یکدیگر بررسی کنم، دست‌کم آن‌ها را به‌صورت روشن مستند کنم و امیدوارم بتوانم راهنمایی ارائه دهم که نشان دهد چگونه می‌توان تا حد ممکن از این مشکلات را حل کرد، بدون اینکه تغییرات زیادی در سینتکس زبان ایجاد شود.

این نوشته را باید به معنای کلاسیک کلمه یک «مقاله تاملی» در نظر گرفت، یعنی می‌خواهم با نوشتن درباره مسئله، ابعاد مختلف آن را بهتر بررسی کنم.

حوزه‌های مسئله

دو حوزه اصلی و هم‌پوشان وجود دارند که با آن‌ها سروکار داریم: مشکل ساخت آبجکت (Construction Problem) و مشکل Value Object. این دو مسئله یکسان نیستند، اما به دلیل نحوه عملکرد فعلی PHP، با یکدیگر هم‌پوشانی دارند و بر هم تاثیر می‌گذارند.

مشکل Constructor

الگوی رایج برای تعریف یک کلاس در PHP امروزی چیزی شبیه به این است:

class Foo {
    private string $aString;

    private int $anInt = 0;

    private Bar $bar;

    private Baz $baz;

    public function __construct(Bar $bar, Baz $baz, string $aString) {
        $this->bar = $bar;
        $this->baz = $baz;
        $this->aString = $aString;

        $this->anInt = strlen($this->aString);
    }
}

این کد بیش از حد طولانی و تکراری است. هر متغیر چهار بار و در سه خط مختلف تکرار شده است. بخش زیادی از این کدها صرفا Boilerplate و فاقد ارزش واقعی هستند. بعضی IDEها می‌توانند در نوشتن بخش زیادی از این کدها کمک کنند، اما تمام مشکل را برطرف نمی‌کنند. این مسئله هم Service Objectها و هم Value Objectها را تحت تاثیر قرار می‌دهد.

هدف: راهی پیدا کنیم که میزان کدنویسی لازم برای این الگوی بسیار رایج را کاهش دهد، بدون اینکه امکان اجرای منطق اضافی هنگام ساخت آبجکت، مانند دستور خط آخر، از بین برود.

مشکل Value Object

PHP مانند جاوا و بسیاری از زبان‌های کلاسیک شی‌گرا (Classic OOP)، تنها یک سازوکار زبانی به نام class دارد که هم برای نمایش Product Type و هم برای ساخت Service Object استفاده می‌شود.

Product Type مقداری است که از ترکیب چند مقدار ساده‌تر تشکیل شده است. در مقابل، Service Object روالی منطقی است که از قبل با تعدادی مقدار، معمولا دیپندنسی‌ها، مقداردهی اولیه شده و سپس می‌توان به‌صورت غیرمستقیم به آن دسترسی داشت.

این رویکرد رایج است، اما می‌توان استدلال کرد که مشکل‌ساز است، زیرا رفتار موردنیاز این دو نوع آبجکت یکسان نیست.

مشکل اصلی Service Objectها همان مشکل Constructor است که در بخش قبل توضیح دادیم. اما Value Objectها علاوه بر آن، با چند چالش دیگر نیز مواجه‌اند.

۱. مشکل Bean

منظور از Bean Problem، قرارداد رایج در Java است که طبق آن هر پراپرتی باید Getter و Setter مخصوص به خود را داشته باشد، یعنی متدهایی با نام‌گذاری مشخص برای خواندن و تغییر مقدار آن پراپرتی.

در چنین حالتی، پراپرتی عملا می‌تواند Public باشد، اما Public تعریف نمی‌شود، چون طبق این قرارداد، نباید پراپرتی‌های Public داشته باشیم!

وقتی یک Value Object واقعا کار دیگری جز نگهداری دو رشته و یک عدد صحیح ندارد، Getterها و Setterهای تشریفاتی چیزی جز شلوغ کردن کد به همراه ندارند.

۲. مشکل Immutability

این مسئله بلافاصله با چالش دوم، یعنی Immutability یا تغییرناپذیری، برخورد می‌کند.

از نظر مفهومی، بیشتر Value Objectها ــ هرچند نه همه آن‌ها ــ باید از بیرون تغییرناپذیر باشند. این ویژگی مزایای زیادی دارد که در اینجا وارد جزئیات آن‌ها نمی‌شوم، اما پیاده‌سازی آن با سینتکس فعلی دشوار است.

ساخت آبجکتی که فقط Getter داشته باشد، ساده است:

class Point {
    private int $x;
    private int $y;

    public function __construct(int $x, int $y) {
        $this->x = $x;
        $this->y = $y;
    }

    public function getX(): int {
        return $this->x;
    }

    public function getY(): int {
        return $this->y;
    }
}

اما این کد بسیار طولانی است و ساخت آبجکت جدید بر اساس آبجکت قبلی را بی‌دلیل دشوار می‌کند. برای مثال، اگر بخواهیم نقطه را به سمت چپ جابه‌جا کنیم، باید تمام آرگومان‌های Constructor را دوباره وارد کنیم.

وقتی یک Value Object تعداد قابل‌توجهی پراپرتی داشته باشد، این کار آزاردهنده می‌شود.

راهکار رایج فعلی استفاده از متدهای Wither است، متدهایی که الگویی مشابه آنچه در PSR-7 رواج یافته دارند:

class Point {
    // ...

    public function withX(int $newX): self {
        $new = clone($this);
        $new->x = $newX;
        return $new;
    }

    public function withY(int $newY): self {
        $new = clone($this);
        $new->y = $newY;
        return $new;
    }
}

این روش کار می‌کند، اما برای حالتی که بسیار رایج است، همچنان مقدار قابل‌توجهی کد تکراری نیاز دارد.

۳. مشکل اعتبارسنجی در آبجکت‌های تغییرناپذیر (Immutable Validation Problem)

با این حال، بسیاری از Value Objectها صرفا مجموعه‌ای از پراپرتی‌ها نیستند که بتوان آن‌ها را به‌سادگی یک Record در نظر گرفت. این آبجکت‌ها ممکن است علاوه بر نگهداری مقادیر، به منطق و اعتبارسنجی‌های دیگری نیز نیاز داشته باشند.

PHP 7.4 امکان بررسی نوع داده در سطح پراپرتی را فراهم می‌کند و دیگر لازم نیست صرفا برای بررسی Type، این کار را داخل Setter انجام دهیم. اما همچنان ممکن است اعتبارسنجی‌های دیگری لازم باشد.

برای مثال:

  • اطمینان از اینکه مقادیر X و Y مثبت هستند.
  • بررسی اینکه یک رشته فقط یکی از مقادیر مشخص‌شده در یک مجموعه محدود را داشته باشد.
  • بررسی اینکه مقدار یک پراپرتی از نوع DateTimeImmutable بعد از مقدار پراپرتی دیگری از همین نوع قرار داشته باشد.

من این مسئله را Immutable Validation Problem می‌نامم.

۴. مشکل Value Objectهای محاسبه‌شده (Materialized Value Object Problem)

چالش دیگر زمانی رخ می‌دهد که مقادیری از روی پراپرتی‌های دیگر محاسبه می‌شوند و از بیرون در دسترس هستند، اما می‌خواهیم نتیجه محاسبه را کش کنیم.

یک مثال ساده این است که firstName و lastName پراپرتی‌های اصلی باشند و fullName مقداری محاسبه‌شده باشد که از ترکیب این دو با یک فاصله به دست می‌آید.

پیاده‌سازی چنین مقادیری با متدها ساده است، اما کد را طولانی و دست‌وپاگیر می‌کند:

class Person {
    private $firstName;
    private $lastName;
    private $fullName;

    public function fullName(): string {
        if (empty($this->fullName)) {
            $this->fullName = $this->firstName . ' ' . $this->lastName;
        }

        return $this->fullName;
    }
}

البته هیچ‌یک از این راهکارها کمکی به Value Objectهایی نمی‌کند که اساسا از نوع Record نیستند، مانند Event Objectهای تعریف‌شده در PSR-14. احتمالا برای این دسته از آبجکت‌ها هم راهکار زیادی وجود ندارد.

۵. مشکل پراپرتی‌های مستندشده (Documented Property Problem)

چالش آخر ــ که هم Service Objectها و هم Value Objectها را درگیر می‌کند، اما بیشتر در Value Objectها دیده می‌شود ــ Documented Property Problem است.

وقتی یک Value Object تعداد زیادی پراپرتی دارد، مشخص کردن اینکه هر آرگومان Constructor به کدام پراپرتی مربوط است، می‌تواند دشوار شود. در Service Objectها تعداد زیاد پراپرتی‌ها ممکن است نشانه‌ای از یک مشکل در طراحی باشد، اما در Value Objectها لزوما چنین نیست.

این مسئله به‌خصوص زمانی دشوار می‌شود که بعضی پراپرتی‌ها اختیاری باشند.

برای مثال، کلاس Uri در PSR-7 دارای هشت پراپرتی است. اگر همه آن‌ها را در Constructor قرار دهیم، نتیجه چیزی شبیه به این خواهد بود:

class Uri implements UriInterface {
    public function __construct(
        string $scheme = '',
        string $authority = '',
        string $userInfo = '',
        string $host = '',
        int $port = 80,
        string $path = '',
        array $query = [],
        string $fragment = ''
    );
}

در این صورت، ساخت آبجکت به شکل زیر انجام می‌شود:

$u = new Uri('', '', '', 'example.com', 80, '', [], 'anchor');

این روش از نظر خودتوصیفی بودن کد (Self-documenting) بسیار ضعیف است، زیرا به‌راحتی نمی‌توان فهمید هر آرگومان به کدام پراپرتی مربوط می‌شود.

جمع‌بندی مشکلات

در مجموع، می‌توان مشکلات مرتبط با مدیریت پراپرتی‌های آبجکت را به این موارد تقسیم کرد:

  • Constructor طولانی: کدنویسی تکراری بیش از حد است.
  • Beanها: Getterها و Setterهای تکراری و غیرضروری ایجاد می‌شوند.
  • Immutability: اعمال تغییرناپذیری به‌صورت دستی انجام می‌شود و کد زیادی می‌خواهد.
  • Evolution: توسعه و تغییر یک آبجکت تغییرناپذیر به کدنویسی تکراری زیادی نیاز دارد.
  • Immutable Validation: معتبر بودن یک Product Type فقط به Typeهای پراپرتی‌های آن محدود نیست.
  • Materialized Value: بعضی مقادیر از بیرون شبیه پراپرتی هستند، اما در واقع محاسبه می‌شوند.
  • Documented Property / Property Order: کار با Constructorهای طولانی و ترتیب آرگومان‌ها دشوار است.

طبیعتا ممکن است درباره میزان اهمیت هرکدام از این مشکلات اختلاف‌نظر داشته باشیم، اما به‌سختی می‌توان وجود آن‌ها را انکار کرد.

بنابراین، هدف ما باید طراحی راهکارهایی باشد که تا حد امکان این مشکلات را حل کنند، بدون اینکه مشکلات دیگر را بدتر کنند یا به پرفورمنس آسیب بزنند.

راهکارهای مطرح‌شده تا امروز

احتمالا بعضی پیشنهادها را از قلم انداخته‌ام. همچنین قصد ندارم جزئیات فنی تمام آن‌ها را بررسی کنم، اما تلاش می‌کنم هر پیشنهاد را دست‌کم به‌درستی معرفی کنم. بعضی از آن‌ها چندین شکل مختلف از نظر سینتکس دارند، بنابراین وارد جزئیات این تفاوت‌ها نمی‌شوم. ترتیب فهرست نیز نشان‌دهنده اولویت نیست.

۱. پراپرتی‌های فقط یک‌بار قابل‌نوشتن (Write-once Properties)

در این پیشنهاد، یک Flag برای پراپرتی در نظر گرفته می‌شود که مشخص می‌کند مقدار آن فقط زمانی قابل‌نوشتن است که هنوز مقداردهی اولیه نشده باشد. پس از اولین مقداردهی، دیگر نمی‌توان مقدار آن را تغییر داد.

مثال:

class Person {
    readonly public string $firstName;
}

مزایا: پس از مقداردهی، می‌توان پراپرتی را Public کرد، بدون اینکه تغییرناپذیری آن نقض شود. به این ترتیب، مشکل Bean تا حد زیادی و مشکل Immutability نیز تا حدی برطرف می‌شود.

معایب: از آنجا که وضعیت Write-once هنگام Clone کردن آبجکت نیز حفظ می‌شود، تغییر و توسعه آبجکت دشوارتر خواهد شد. این پیشنهاد کمکی به حل مشکلات Validation و Materialized Value نمی‌کند. همچنین اگر پراپرتی در Constructor مقداردهی نشود، ممکن است کد دیگری بعدا آن را مقداردهی کند و نتیجه‌ای نامشخص و حتی غیرقابل‌پیش‌بینی به وجود آورد.

۲. انتساب فشرده پراپرتی‌های آبجکت (Compact Object Property Assignment)

در این پیشنهاد، سینتکس کوتاه‌تری برای مقداردهی پراپرتی‌های Public از بیرون آبجکت ارائه می‌شود.

مثال:

$f = (new Foo)->[
    a = 1,
    b = 2,
    c = 3,
];

مزایا: به حل مشکلات Constructor طولانی و Documented Property کمک می‌کند.

معایب: چون فقط برای پراپرتی‌های Public کاربرد دارد، مشکلات Immutability و Immutable Validation را بدتر می‌کند. ترکیب این روش با Write-once Properties تا حدی می‌تواند این مشکل را کاهش دهد، اما چنین ترکیبی فقط برای Value Objectها کاربرد دارد.

۳. آرگومان‌های مبتنی بر Associative Array

در این روش، یک آرایه Associative به Constructor یا متد دیگری ارسال می‌شود که کلیدهای آن مشخص می‌کنند کدام پراپرتی باید مقداردهی شود.

مثال:

class Person {
    private string $firstName;
    private string $lastName;

    public function __construct(array $properties) {
        $this->firstName = $properties['firstName'];
        $this->lastName = $properties['lastName'];
    }
}

مزایا: تا حد زیادی مشکل Documented Property را حل می‌کند و بدون تغییر زبان، همین حالا هم می‌توان آن را در کدهای PHP پیاده‌سازی کرد.

معایب: این روش به قیمت از دست رفتن Type Safety تمام می‌شود و کد داخلی آبجکت همچنان به Boilerplate بیشتری نیاز دارد. بررسی اینکه تمام پراپرتی‌های ضروری مقداردهی شده‌اند نیز کاملا بر عهده برنامه‌نویس است.

۴. کنترل دسترسی وابسته به کانتکست (Contextual Access Control)

این پیشنهاد اجازه می‌دهد یک پراپرتی از بیرون قابل‌خواندن باشد، اما فقط از داخل کلاس و در صورت نیاز، کلاس‌های فرزند آن قابل‌نوشتن باشد.

مثال:

class Person {
    privatewrite publicread string $firstName;
    privatewrite publicread string $lastName;

    public function __construct(string $firstName, string $lastName) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

مزایا: مشکل Bean را کاهش می‌دهد و در عین حال Immutability را حفظ می‌کند. همچنین ممکن است برای حل مشکل Materialized Value مفید باشد.

معایب: سینتکس آن ممکن است خیلی زود پیچیده و شلوغ شود. علاوه بر این، امکان دارد با سایر پیشنهادهای این فهرست تداخل پیدا کند. با این حال، به نظر من این پیشنهاد ارزش بررسی دارد.

۵. پارامترهای نام‌دار (Named Parameters)

در این پیشنهاد، متدها (یا دست‌کم متدهایی که برای این قابلیت مشخص شده‌اند) می‌توانند آرگومان‌ها را به شکل مجموعه‌ای از پارامترهای نام‌دار و با هر ترتیبی دریافت کنند.

این روش را می‌توان نسخه‌ای Type-safe از آرگومان‌های Associative Array در نظر گرفت.

مثال:

class Person {
    private string $firstName;
    private string $lastName;

    public function __construct(string $firstName, string $lastName) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

$p = new Person({lastName: 'Garfield', firstName: 'Larry'});

مزایا: مشکل Documented Property را حل می‌کند، زیرا مشخص است هر آرگومان به کدام پارامتر مربوط می‌شود.

معایب: اگر پراپرتی‌ها آبجکت‌های تو‌در‌تو باشند، سینتکس ممکن است بسیار طولانی و نامناسب شود. این روش مشکلات دیگر را برطرف نمی‌کند. همچنین نام متغیرها را به بخشی از اینترفیس تابع تبدیل می‌کند، در حالی که پیش از این چنین الزامی وجود نداشت. این تغییر می‌تواند به‌صورت ظریفی احتمال شکستن سازگاری رو به عقب (Backward Compatibility) را ایجاد کند.

۶. سینتکس اختصاصی برای مقداردهی اولیه آبجکت (Object Initializer Syntax)

این پیشنهاد سینتکس اختصاصی‌ای برای ساخت آبجکت ارائه می‌دهد که پارامترها را به پراپرتی‌های مربوط به آن‌ها متصل می‌کند.

مثالی از RFC مربوطه:

$customer = new Customer {
    id = 123,
    name = "John Doe",
};

مزایا: حالت رایج تکرار سینتکس در Constructor را برطرف می‌کند.

معایب: دامنه آن بسیار محدود است و به حل مشکلات دیگر کمکی نمی‌کند. RFC موردنظر نیز فقط درباره پراپرتی‌های Public صحبت می‌کرد که همان محدودیت‌های Compact Object Property Assignment را دارد. از این نظر، می‌توان آن را تقریبا همان پیشنهاد دانست.

۷. کلیدواژه default برای پارامترهای تابع

در این پیشنهاد، کلیدواژه‌ای به نام default اضافه می‌شود که می‌توان آن را به هر پارامتری که مقدار پیش‌فرض دارد ارسال کرد. در این صورت، مقدار پیش‌فرض آن پارامتر استفاده می‌شود.

مثال:

$u = new Uri(default, default, default, 'example.com', 80, default, default, 'anchor');

مزایا: راهکاری محدود و مشخص برای بهبود Documented Property است و مشکل دیگری را بدتر نمی‌کند.

معایب: فایده آن واقعا محدود است، زیرا همچنان باید آرگومان‌های زیادی بنویسیم و مشخص نیست هر آرگومان به کدام پارامتر مربوط می‌شود.

۸. ارتقای کانستراکتور (Constructor Promotion)

این پیشنهاد یک سینتکس کوتاه برای الگوی رایج «دریافت پارامتر و انتساب آن به پراپرتی هم‌نام» ارائه می‌دهد.

با استفاده از سینتکس پشتیبانی‌شده در Hack، مثال ابتدای مقاله به شکل زیر درمی‌آید:

class Foo {
    private int $anInt = 0;

    public function __construct(
        private Bar $bar,
        private Baz $baz,
        private string $aString
    ) {
        $this->anInt = strlen($this->aString);
    }
}

این کد از نظر منطقی دقیقا معادل نسخه طولانی‌تر قبلی است.

این قابلیت همچنین اجازه می‌دهد آبجکت‌هایی که صرفا مجموعه‌ای از پراپرتی‌های Public هستند، به شکل ساده‌تری نوشته شوند:

class Point {
    public function __construct(
        public int $x,
        public int $y
    ) {}
}

مزایا: حجم کد تکراری را به میزان زیادی کاهش می‌دهد و به نظر نمی‌رسد هیچ‌یک از حوزه‌های مسئله دیگر را بدتر کند.

معایب: ممکن است مدتی طول بکشد تا برنامه‌نویسان به سینتکس آن عادت کنند. همچنین اگر Modifierهای بیشتری برای پراپرتی‌ها اضافه شوند، مانند readonly یا publicread، سینتکس می‌تواند عجیب و شلوغ شود، مگر اینکه پارامترها به‌صورت عمودی نوشته شوند که امروزه در عمل چندان رایج نیست.

۹. Clone کردن به سبک Rust (Rust-like Cloning)

Rust زبان بسیار متفاوتی از PHP است، اما این تفاوت قبلا هم مانع الهام گرفتن PHP از ایده‌های آن نشده است!

در Rust، Structها می‌توانند پراپرتی‌های خود را از Struct دیگری با همان Type دریافت کنند.

مثال:

struct Point {
    x: f32,
    y: f32,
}

let p1: Point = Point { x: 10.3, y: 0.4 };
let p2 = Point { y: 5, x: p1.x };

این کد را می‌توان به شکل کوتاه‌تری نوشت:

let p2 = Point { y: 5, .. p1 };

معنای آن این است: «یک Struct جدید از نوع Point بساز که مقدار y آن برابر با ۵ باشد و سایر مقادیر را از p1 کپی کن.»

در Rust، جزئیات بیشتری درباره تفاوت Copy و Clone و سیستم مدیریت حافظه وجود دارد که در اینجا به آن‌ها نیازی نداریم، زیرا مدل حافظه PHP متفاوت است.

معادل احتمالی این قابلیت در PHP می‌تواند چیزی شبیه به این باشد:

$p2 = new Point({y: 5, .. $p1});

مزایا: در عمل ترکیبی از متد Wither و Constructor است که می‌توان آن را از بیرون آبجکت فراخوانی کرد. این قابلیت در کنار Named Parameters یا Constructor Promotion نیز به‌خوبی عمل می‌کند.

معایب: فقط در مواردی کاربرد دارد که پراپرتی مستقیما با یک پارامتر Constructor متناظر باشد، بنابراین دامنه استفاده از آن محدود است. این روش به ولیدیشن کمکی نمی‌کند و ممکن است برای پراپرتی‌های محاسبه‌شده ابهام ایجاد کند. با این حال، اگر فقط از داخل کلاس قابل‌استفاده باشد، می‌تواند نوشتن متدهای Wither را ساده‌تر کند.

۱۰. پراپرتی Accessorها

این پیشنهاد اجازه می‌دهد برای پراپرتی‌ها متدهای get و set با سینتکس مشخصی تعریف کنیم که هنگام خواندن یا نوشتن پراپرتی فراخوانی شوند. در واقع، می‌توان آن‌ها را نسخه‌ای نام‌دار از __get و __set در نظر گرفت.

زبان‌های دیگری مانند پایتون و جاوااسکریپت نیز قابلیت‌های مشابهی دارند.

نمونه‌ای از RFC مربوط به این پیشنهاد:

class TimePeriod {
    private $Seconds;  // پراپرتی معمولی

    public $Hours {
        get() {
            return $this->Seconds / 3600;
        }

        protected set($x) {
            $this->Seconds = $x * 3600;
        }

        isset() {
            return isset($this->Seconds);
        }

        unset() {
            unset($this->Seconds);
        }
    }
}

مزایا: این پیشنهاد مزایای زیادی دارد. امکان تعریف پراپرتی‌هایی را فراهم می‌کند که از بیرون Public به نظر می‌رسند و در نتیجه مشکل Bean را کاهش می‌دهند، اما می‌توان نوشتن در آن‌ها را از بیرون محدود کرد تا Immutability حفظ شود.

همچنین می‌توان بدون تغییر API، منطق ولیدیشن را در مسیر دسترسی به پراپرتی قرار داد. پراپرتی‌ها می‌توانند به‌صورت Lazy از مقادیر دیگر محاسبه شوند و حتی می‌توان این قابلیت را در اینترفیس‌ها تعریف کرد، در نتیجه، مفاهیمی شبیه پراپرتی نیز قابلیت تعریف در اینترفیس را پیدا می‌کنند.

اگر به منطق Write-once نیاز باشد، می‌توان آن را در سطح کدهای معمول PHP پیاده‌سازی کرد، هرچند احتمالا با حل شدن کاربرد اصلی این قابلیت از طریق راهکارهای دیگر، نیاز کمتری به آن خواهیم داشت.

این پیشنهاد را می‌توان رویکردی جامع برای مدیریت پراپرتی‌ها دانست که تعداد زیادی از مشکلات را حل می‌کند. تنها مشکلاتی که مستقیما برطرف نمی‌کند، Evolution و مسائل اختصاصی Constructor هستند.

معایب: مهم‌ترین ایراد پراپرتی Accessorها (و به نظر من تنها ایراد اساسی آن‌ها) این است که مشخص نیست عبارت $foo->bar به یک پراپرتی اشاره می‌کند یا متدی پنهان را اجرا می‌کند.

این مسئله از نظر برنامه‌نویسان قابل بحث است، اما برای Engine نیز چالش ایجاد می‌کند، زیرا در هر بار Dereference باید یک شرط در سطح C بررسی شود که می‌تواند هزینه عملکردی قابل‌توجهی داشته باشد.

تا جایی که می‌دانم، این مسئله یکی از مهم‌ترین دلایل مخالفت با این پیشنهاد در گذشته بوده است. اختلاف‌نظر درباره سینتکس مناسب نیز به این چالش اضافه می‌شود، چراکه روش‌های متعددی برای ساختاردهی آن وجود دارد.

این رویکرد تنها در صورتی عملی خواهد بود که بتوان مشکل پرفورمنس را به شکلی حل کرد. من تخصصی در توسعه Engine ندارم و نمی‌توانم راهکار فنی موثری برای این موضوع ارائه دهم.

تحلیل و پیشنهادها

فکر می‌کنم بیشتر پیشنهادهای مطرح‌شده در این زمینه را پوشش داده‌ام. اگر موردی را از قلم انداخته باشم، می‌توانید آن را مطرح کنید تا به فهرست اضافه کنم. بر اساس جمع‌بندی بالا، تحلیل و پیشنهادهای من به این صورت است.

پیشنهادهای مربوط به انتساب فشرده پراپرتی‌های آبجکت، از نظر من گزینه‌های مناسبی برای ادامه نیستند. دامنه کاربردشان بسیار محدود است، با بسیاری از گزینه‌های دیگر سازگاری ندارند و فقط برای پراپرتی‌های Public کار می‌کنند. در بهترین حالت، کاربرد آن‌ها حاشیه‌ای خواهد بود.

به همین ترتیب، کلیدواژه default برای پارامترهای تابع نیز بیش از حد محدود است و به نظرم بهتر است در نتیجه یک تغییر جامع‌تر به دست بیاید. این قابلیت مشکل خاصی ایجاد نمی‌کند، اما کمک چندانی هم نمی‌کند.

آرگومان‌های Associative Array نیز به‌جز اینکه همین حالا قابل استفاده‌اند، مزیت خاصی ندارند. در واقع، هدف ما این است که از چنین راهکارهایی فاصله بگیریم.

پیشنهاد Write-once Properties هم امیدوارکننده است، اما محدودیت‌های آن و تاثیر منفی‌اش بر برخی مشکلات دیگر باعث می‌شود به نظرم گزینه مناسبی برای ادامه نباشد.

از نظر من، پیشنهادهای واقعا امیدوارکننده عبارت‌اند از:

  • Constructor Promotion
  • Named Parameters
  • Contextual Access Control
  • Property Accessors
  • و احتمالا ایده‌هایی برگرفته از Rust

اجازه دهید توضیح بدهم چرا.

ساده‌سازی Constructor و Named Parameters

مشکلات مرتبط با Constructor در واقع از ترکیب دو مسئله به وجود می‌آیند: سینتکس تکراری و محدودیت در ترتیب آرگومان‌ها.

حل مستقل هرکدام از این دو مسئله مزایای خاص خود را دارد. در عین حال، این دو قابلیت مکمل یکدیگر هستند و ترکیب آن‌ها می‌تواند کار با Constructorها را بسیار ساده‌تر کند.

بدون اینکه فعلا درگیر جزئیات سینتکس شویم، مثال زیر را در نظر بگیرید:

class Uri implements UriInterface {
    public function __construct({
        private string $scheme,
        private string $authority,
        private string $userInfo,
        private string $host,
        private int $port,
        private string $path,
        private array $query,
        private string $fragment,
    }) {
        if ($port < 0) {
            throw new \LogicException();
        }
    }
}

این سینتکس عمدتا از Hack گرفته شده است، با این تفاوت که {} به فهرست آرگومان‌ها اضافه شده است. این علامت نشان می‌دهد که نام پارامترها بخشی از اینترفیس محسوب می‌شود و بنابراین می‌توان از آن‌ها برای ارسال Named Parameters استفاده کرد.

در نتیجه، هر دو شیوه ساخت زیر امکان‌پذیر خواهند بود:

$u1 = new Uri('', '', '', 'example.com', 80, '', [], 'anchor');

$u2 = new Uri({
    host: 'example.com',
    fragment: 'anchor',
});

مزایای این رویکرد عبارت‌اند از:

  • تکرار غیرضروری کد در حالتی که پارامترهای Constructor مستقیما به پراپرتی‌ها نگاشت می‌شوند، حذف می‌شود.
  • امکان اجرای منطق اضافی در Constructor همچنان حفظ می‌شود.
  • Type Safety حفظ می‌شود.
  • در صورت نیاز، ساخت آبجکت خواناتر و خودتوصیفی‌تر خواهد بود.
  • این روش برای هر نوع Visibility از پراپرتی کاربرد دارد، نه فقط پراپرتی‌های Public.
  • چون این قابلیت از دو بخش مستقل تشکیل شده است، پیاده‌سازی‌کنندگان می‌توانند یکی از آن‌ها یا هر دو را انتخاب کنند.
  • برای کد فراخواننده شفاف است، یعنی سینتکس فراخوانی معمولی و ترتیبی Constructor تغییر نمی‌کند و چه کلاس از Constructor Promotion استفاده کند و چه از Named Parameters، این شیوه همچنان کار خواهد کرد.
  • حتی اگر منطق Constructor امکان استفاده از Auto-promotion را نداشته باشد، Named Parameters همچنان قابل استفاده است. این قابلیت مستقل از آن است که آبجکت یک Struct ساده باشد، یک Service Object باشد یا منطق پیچیده‌ای در Constructor داشته باشد.
  • Named Parameters را می‌توان به هر متد یا تابع دیگری نیز اضافه کرد. این قابلیت به‌تنهایی ارزشمند است.

از نظر من، این رویکرد ایراد مشخصی ندارد.

گزینه‌های اختیاری

چند موضوع دیگر نیز وجود دارد که می‌توان درباره آن‌ها بحث کرد:

  • می‌توان بررسی کرد که آیا استفاده از Named Parameters باید به‌صورت Opt-in باشد یا خیر. من با هر دو حالت مشکلی ندارم و فکر نمی‌کنم این تصمیم برای اصل قابلیت تعیین‌کننده باشد.
  • می‌توان امکان ترکیب پارامترهای ترتیبی و نام‌دار را مانند پایتون در نظر گرفت، اما به نظرم ضروری نیست.
  • اگر نگرانی زیادی درباره پیامدهای این قابلیت وجود داشته باشد، می‌توان در ابتدا Named Parameters را به Constructorها محدود کرد.

ترکیب این دو قابلیت تقریبا تمام مشکلات شناسایی‌شده در زمینه Constructor را حل می‌کند.

ساده‌تر کردن Evolution با ایده‌ای از Rust

این دو قابلیت به‌تنهایی مشکل Evolution را حل نمی‌کنند. با این حال، فکر می‌کنم افزودن قابلیت کوچکی با الهام از Rust بتواند به این مسئله کمک کند.

این سینتکس را در نظر بگیرید:

$u2 = new Uri({
    host: 'example.com',
    fragment: 'anchor',
});

$u3 = new Uri({
    port: 443,
    .. $u2
});

معنای این کد آن است که هنگام ساخت یک Uri جدید، مقدار port برابر با ۴۴۳ قرار می‌گیرد و سایر پارامترها از پراپرتی‌های قابل‌دسترسی آبجکت به نام $u2 خوانده می‌شوند.

اگر تمام پراپرتی‌های $u2 از نوع Private باشند، اجرای این کد از بیرون کلاس با شکست مواجه می‌شود. اما استفاده از آن درون یک متد Wither امکان‌پذیر خواهد بود.

در این صورت، بیشتر متدهای Wither را می‌توان به شکل زیر نوشت:

public function withPort(int $port): self {
    return new static({
        port: $port,
        .. $this
    });
}

این تغییر کوچک است، اما بهبود خوبی به شمار می‌رود. همچنین باعث می‌شود به فکر راهی برای خودکارسازی کامل این سینتکس بیفتم، هرچند در حال حاضر ایده مشخصی برای آن ندارم. این موضوع می‌تواند به زمان دیگری موکول شود.

تاکید می‌کنم که این قابلیت یک افزونه اختیاری است و تنها پس از پیاده‌سازی مستقل Constructor Promotion و Named Parameters مطرح می‌شود.

کنترل دسترسی ترکیبی برای پراپرتی‌ها

به‌عنوان مکمل، هرچه بیشتر به این موضوع فکر می‌کنم، بیشتر به این نتیجه می‌رسم که نوعی از Contextual Access Control می‌تواند بهترین راهکار کوتاه‌مدت باشد، البته به شرطی که سینتکس آن مانع پیاده‌سازی پراپرتی Accessorهای کامل در آینده نشود.

این موضوع ما را به سینتکس احتمالی زیر می‌رساند:

class Person {
    string $firstName {
        public get;
        private set;
    };

    public string $lastName {
        private set;
    };

    public function __construct(string $firstName, string $lastName) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

این سینتکس زیرمجموعه محدودی از پیشنهاد Property Accessors است، اما راهی روشن برای تعریف سطح دسترسی متفاوت در Contextهای مختلف ارائه می‌دهد.

در مثال بالا، هر دو پراپرتی به‌صورت Public قابل‌خواندن هستند، اما فقط از داخل کلاس می‌توان در آن‌ها نوشت، نه از داخل کلاس‌های فرزند. برای اجازه دادن به کلاس‌های فرزند، می‌توان از کلیدواژه protected set استفاده کرد.

این سینتکس همچنین امکان گسترش طبیعی به Accessorهای کامل را در آینده فراهم می‌کند، به شرط آنکه مشکلات پرفورمنس مربوط به آن‌ها حل شوند.

نمی‌دانم آیا چنین رویکردی مانند Accessorهای کامل بر Performance Engine تاثیر می‌گذارد یا خیر. حدس من این است که هزینه آن از بررسی‌های دسترسی موجود بیشتر نخواهد بود، اما پاسخ دقیق‌تر به دانش تخصصی درباره Engine نیاز دارد.

امکان تعریف پراپرتی‌های Public در اینترفیس نیز می‌تواند بخشی اختیاری از این پیشنهاد باشد. من حتی اگر تغییرات فقط به همین اندازه محدود شوند، از آن حمایت می‌کنم، اما این قابلیت جداگانه است و برای اجرای اصل پیشنهاد ضرورت ندارد.

ترکیب این قابلیت‌ها با Constructor Promotion

یکی از معایب این سینتکس آن است که ترکیبش با Constructor Promotion ممکن است به‌دلیل {} اضافی کمی شلوغ شود. برای مثال:

class Uri implements UriInterface {
    public function __construct({
        string $scheme = '' {
            public get,
            private set
        },
        string $authority = '' {
            public get,
            private set
        },
        string $userInfo = '' {
            public get,
            private set
        },
        string $host = '' {
            public get,
            private set
        },
        int $port = 80 {
            public get,
            private set
        },
        string $path = '' {
            public get,
            private set
        },
        array $query = [] {
            public get,
            private set
        },
        string $fragment = '' {
            public get,
            private set
        },
    }) {
        if ($port < 0) {
            throw new \LogicException();
        }
    }
}

این سینتکس ایده‌آل نیست، اما در عین حال چندان هم بد نیست. کمی درباره نحوه تعامل این قابلیت‌ها با یکدیگر نگرانم، به‌خصوص اگر پراپرتی Accessorهای کامل در آینده اضافه شوند، با این حال، فکر می‌کنم این پیشنهاد امیدوارکننده است و با کمی اصلاح می‌توان ترتیب کلیدواژه‌ها را بهتر کرد.

برای روشن شدن نتیجه، کلاسی که در بالا تعریف کردیم، ویژگی‌های زیر را خواهد داشت:

  • می‌توان آن را با پارامترهای ترتیبی یا Named Parameters ساخت.
  • تمام پراپرتی‌های آن به‌صورت Public و فقط خواندنی در دسترس خواهند بود.
  • از داخل کلاس می‌توان تمام پراپرتی‌ها را خواند و تغییر داد.
  • همچنان می‌توان هر متدی را که نیاز داریم به آن اضافه کرد.
  • می‌توان آن را با اطمینان Clone کرد، بنابراین متدهای Wither مانند گذشته کار خواهند کرد.
  • در صورت نیاز، همچنان می‌توان Setterهای Public تعریف کرد.
  • اجرای منطق دلخواه در Constructor امکان‌پذیر خواهد بود.
  • هیچ‌یک از مشکلات شناسایی‌شده را بدتر نمی‌کند.
  • خوش‌طعم‌تر است و سیرکنندگی کمتری دارد!

این قابلیت همچنین سینتکس الهام‌گرفته از Rust برای ساخت آبجکت از روی آبجکت دیگر را کاربردی‌تر می‌کند، زیرا می‌توان پراپرتی‌ها را برای خواندن از بیرون Public کرد، بدون اینکه امکان نوشتن در آن‌ها نیز از بیرون فراهم شود.

نتیجه‌گیری و خلاصه

این مقاله مروری بر چند چالش سینتکس در PHP بود. مسیر بحث دقیقا به همان جایی ختم نشد که در ابتدا انتظار داشتم، اما همین موضوع نشان می‌دهد که بررسی مسئله از طریق نوشتن درباره آن مفید بوده است.

بر اساس تحلیل بالا، فکر می‌کنم چهار RFC زیر بیشترین ارزش را برای بهبود تجربه کار با آبجکت‌ها در PHP دارند. هرکدام به‌تنهایی مزایایی ارائه می‌کنند، اما در کنار یکدیگر می‌توانند نتیجه‌ای به‌مراتب بهتر از مجموع مزایای جداگانه‌شان ایجاد کنند.

۱. Constructor Promotion

می‌توان سینتکس آن را مستقیما از Hack اقتباس کرد. این قابلیت با هزینه‌ای اندک، حجم کد تکراری در Constructorها را به‌شدت کاهش می‌دهد.

۲. توابع دارای Named Parameters

می‌توان این قابلیت را به Constructorها محدود کرد، اما به نظر من چنین محدودیتی ضروری نیست. ترجیح من این است که تمام توابع و متدها از Named Parameters پشتیبانی کنند، چه استفاده از آن‌ها به‌صورت Opt-in باشد و چه نباشد.

۳. کنترل دسترسی ترکیبی پراپرتی‌ها (Compound Property Visibility)

این قابلیت با استفاده از سینتکس الهام‌گرفته از Property Accessors، روشی تمیز برای تعریف Value Objectهایی فراهم می‌کند که استفاده از آن‌ها از بیرون ساده است، اما در داخل کلاس می‌توانند به هر میزان که نیاز دارند پیچیده باشند.

در این رویکرد، برنامه‌نویسان مجبور نمی‌شوند پراپرتی‌ها را Public کنند، در حالی که چنین کاری از نظر طراحی مناسب نیست.

سینتکس پیشنهادی همچنین می‌تواند در آینده به پراپرتی Accessorهای کامل گسترش پیدا کند، به شرط آنکه چالش‌های پرفورمنس آن‌ها قابل‌حل باشند. حتی اگر این مشکلات حل نشوند، سینتکس پیشنهادی به‌اندازه کافی روشن و قابل‌فهم است.

۴. سینتکس ساخت آبجکت از روی آبجکت دیگر (Construct-from Syntax)

این قابلیت با الهام از Rust، نوشتن متدهای Wither را کوتاه‌تر می‌کند و در کنار Compound Property Visibility امکان ساخت نسخه‌ای از یک آبجکت با یک تغییر مشخص را فراهم می‌کند، بدون اینکه لازم باشد پراپرتی‌ها برای نوشتن از بیرون Public باشند.

از نظر من، این قابلیت یک افزونه اختیاری است و باید آخر از همه پیاده‌سازی شود. سه قابلیت دیگر را می‌توان به‌صورت موازی پیش برد.

مشکلاتی که این تغییرات حل می‌کنند

در مجموع، این چهار تغییر تقریبا تمام مشکلات شناسایی‌شده را برطرف خواهند کرد:

  • Constructor طولانی: با Constructor Promotion و Named Parameters به‌طور کامل حل می‌شود.
  • Beanها: با Compound Property Visibility دیگر نیازی به Getterها و Setterهای تکراری نخواهد بود.
  • Immutability: همچنان مسئولیت تضمین آن در سطح کدهای PHP باقی می‌ماند، اما انجام این کار بسیار ساده‌تر خواهد شد.
  • Evolution: بسته به سرنوشت Construct-from Syntax، وضعیت آن بدون تغییر می‌ماند یا تا حدی بهبود پیدا می‌کند.
  • Immutable Validation: متدهای Setter و Wither همچنان می‌توانند منطق دلخواه را پیاده‌سازی کنند.
  • Materialized Value: وضعیت آن تا حد زیادی بدون تغییر می‌ماند و برای حل کامل آن باید منتظر پراپرتی Accessorهای کامل باشیم.
  • Documented Property / Property Order: با Named Parameters برطرف می‌شود.

همان‌طور که اشاره کردم، تخصص من در توسعه خود PHP Engine بسیار محدود است. با این حال، خوشحال می‌شوم در هر سطحی که بتوانم با افرادی که دانش کافی درباره Engine دارند مشورت کنم، همکاری داشته باشم یا به پیشبرد این ایده‌ها کمک کنم.

حتی اگر کسی صرفا پیشنهادهای مطرح‌شده در این مقاله را بردارد و خودش کار را ادامه دهد، برای من قابل‌قبول است، مهم این است که کار پیش برود، نه اینکه چه کسی آن را انجام می‌دهد.

دیدگاه‌ها


ثبت دیدگاه