لغات البرمجة عملياً الأدلة التقنية

الأنواع Types والذاكرة والأخطاء في PHP 8 بتجارب مقيسة

نحسب ضريبة مئة ألف سعر بطريقتين ونقيس جامع الدورات وذاكرة Worker طويل ونشغل كل نوع من أنواع الفشل في PHP 8.5، لترى أين يخطئ من يكتب PHP بعقل لغة أخرى.

الأنواع Types والذاكرة والأخطاء في PHP 8 بتجارب مقيسة

في مقال PHP لمن يعرف البرمجة بدأنا العمل على متجر صغير يبيع القهوة والتمور، وكتبنا دالة الضريبة واختبارها، ورأينا أن PHPStan كشف منتجاً غير موجود كان PHP يحسب سعره صفراً بصمت. والآن في الأسبوع الثاني تصلك من فريق الدعم ثلاث شكاوى في يوم واحد: عميل دفع ثمن علبة هيل بسعر 1.50 ريال وظهرت ضريبتها في الفاتورة 22 هللة بينما يقول المحاسب إنها 23، وطلب وصل من تطبيق الجوال فارغاً تماماً دون أي رسالة خطأ في السجلات، وعملية Worker تعالج الطلبات في الخلفية يزيد استهلاكها للذاكرة كل يوم حتى يعيد أحدهم تشغيلها. وهذه الشكاوى الثلاث لها نفس الجذر: أنت تكتب PHP بعقل لغة أخرى، ولم تتعرف بعد على طريقة PHP في الأنواع Types، وفي إدارة الذاكرة Memory Management، وفي الأخطاء Errors.

وهذا المقال هو الثاني في مرحلة لغة PHP للمطورين، وسوف نأخذ فيه كل شكوى من الثلاث إلى جذرها بتجربة تعمل فعلاً على PHP 8.5.12: نحسب الضريبة لمئة ألف سعر بطريقتين ونعد الفروق، ونصنع مئتي ألف كائن في دورات ونقيس الذاكرة مع جامع الدورات وبدونه، ونشغل Worker يعالج مئتي ألف مهمة ونرى ذاكرته تكبر، ثم نشغل كل نوع من أنواع الفشل في PHP ونقرأ ما يطبعه. والمفاهيم العامة شرحناها في مقالات مستقلة: أنظمة الأنواع في أنماط البرمجة وأنظمة الأنواع، وطرق إدارة الذاكرة في إدارة الذاكرة، وأساليب الأخطاء في كيف تتعامل لغات البرمجة مع الأخطاء، لذلك سوف نركز هنا على ما يخص PHP فقط.

وسوف نناقش في هذا المقال ما يلي:

  • كيف يمثل PHP القيم من الداخل، ولماذا هو ديناميكي الأنواع ويفحص الأنواع وقت التشغيل في نفس الوقت.
  • الأرقام Numbers: تجاوز الحد Overflow، والأرقام العشرية float، وتجربة الضريبة على مئة ألف سعر، والتقريب Rounding.
  • النصوص الرقمية Numeric Strings والتحويل Casting، ثم التصريحات Type Declarations: المتحد والتقاطع وDNF وnever والخصائص ذات النوع والتعداد.
  • الأنواع العامة Generics التي لا توجد إلا في PHPStan.
  • من يحرر الذاكرة: عداد المراجع Reference Counting وجامع الدورات Cycle Collector مقيسين بالأرقام، وmemory_limit، وWeakMap.
  • العمليات الطويلة Long-Running Workers وتسرب الذاكرة فيها.
  • الأخطاء: التحذير Warning والخطأ Error والاستثناء Exception، وكيف تحول التحذيرات إلى استثناءات، وfinally و#[\NoDiscard]، وإعدادات الأخطاء في التطوير والإنتاج.
  • مشكلتان ظهرتا في التجربة، وتجربة «ماذا يحدث إذا»، وأخطاء شائعة، وقائمة تحقق.

كيف يمثل PHP القيمة Value من الداخل؟

كل قيمة في PHP، سواءً كانت رقماً أو نصاً أو مصفوفة أو كائناً، تحفظ داخل المحرك في بنية صغيرة اسمها zval، وفيها شيئان: علامة النوع Type Tag تقول هذا int أو string أو array، والقيمة نفسها أو مؤشر إليها. والأرقام الصحيحة والعشرية والقيم المنطقية تحفظ داخل الـ zval مباشرة، أما النصوص والمصفوفات والكائنات فتحفظ في مكان آخر في الذاكرة ومعها عداد مراجع Reference Count يقول كم متغيراً يشير إليها، وهذا ما يشرحه توثيق أساسيات عداد المراجع.

وهذا التصميم يفسر لك صفتين في PHP قد تبدوان متعارضتين. الأولى أن PHP ديناميكي الأنواع Dynamically Typed: المتغير نفسه ليس له نوع، وإنما القيمة التي فيه، فيمكن أن يحمل $x رقماً ثم نصاً. والثانية أن PHP يفحص الأنواع وقت التشغيل Runtime Type Checking: عندما تكتب function addHalalas(int $a) فالمحرك ينظر إلى علامة النوع في الـ zval الذي وصل، ويرفضه إذا لم يكن int. وهنا يختلف PHP عن Python التي لا تفحص التلميحات أبداً، وعن TypeScript التي تفحص قبل التشغيل ثم تمحو الأنواع، فـ PHP يقع في المنتصف: لا يوجد فحص قبل التشغيل إلا بأداة خارجية مثل PHPStan، ولكن كل دالة عليها أنواع تتحقق منها في كل استدعاء.

ثلاثة صناديق للأنواع: البسيطة مثل int وfloat وstring وbool وarray والكائنات والتعداد، ثم الأنواع المركبة مثل int|string و?Product وA&B و(A&B)|null، ثم الخاصة مثل void وnever وmixed، وتحتها صندوقان: ما يفحصه PHP وقت التشغيل مع مثال TypeError وخطأ الخاصية غير المهيأة، وما يفحصه PHPStan فقط مثل TypedList<int> التي قبلت نصاً وقت التشغيل
ما تكتبه على الدوال والخصائص يفحص في كل استدعاء، وما تكتبه في تعليقات PHPDoc يفحصه PHPStan فقط

الأرقام Numbers: int وfloat وما بينهما

في PHP نوعان من الأرقام فقط: int وهو رقم صحيح بحجم 64 بت على كل الأنظمة الحديثة، وfloat وهو رقم عشري بمعيار IEEE 754 بدقة مضاعفة Double Precision، ولا يوجد decimal مدمج كما في C# أو BigInteger كما في Java. وأول ما شغلناه في الملف types.php كان حدود هذين النوعين:

var_dump(PHP_INT_MAX + 1);
var_dump(0.1 + 0.2 === 0.3, 9.90 * 3);
printf("%.20f\n", 9.90 * 3);
var_dump(7 / 2, intdiv(7, 2), -7 % 3, fmod(-7.5, 2));
var_dump(round(2.5), round(-2.5), round(4.455, 2));

والمخرج سوف يكون كما يلي:

float(9.223372036854776E+18)
bool(false)
float(29.700000000000003)
29.70000000000000284217
float(3.5)
int(3)
int(-1)
float(-1.5)
float(3)
float(-3)
float(4.46)

في المخرج أعلاه لاحظ التالي:

  • PHP_INT_MAX + 1 لم يرم خطأ تجاوز Overflow كما في C# مع checked، ولم يلتف إلى رقم سالب كما في C وJava، وإنما تحول بصمت إلى float، وفقد دقته لأن الـ float يحفظ 53 بت فقط للأرقام المعنوية. وهذا السلوك موثق في صفحة الأعداد الصحيحة، ونتيجته أن معرفاً رقمياً كبيراً جاء من نظام آخر قد يتغير آخر رقمين فيه دون أي تنبيه.
  • 9.90 * 3 أعطت 29.700000000000003، وهذه ليست مشكلة في PHP، وإنما في تمثيل الأرقام العشرية بالنظام الثنائي في كل اللغات، وقد شرحنا السبب بالتفصيل في مقال الأرقام العشرية Floating Point من الداخل.
  • القسمة / تعطي float دائماً عندما لا تكون القسمة تامة، والقسمة الصحيحة لها دالة مستقلة intdiv، أما Python فلها عامل //.
  • باقي القسمة -7 % 3 يساوي -1 في PHP، أي أنه يأخذ إشارة المقسوم كما في C وJava وJavaScript، أما Python فتعطيك 2. وهذا الفرق يظهر عندما تحسب يوم الأسبوع أو رقم الدفعة من رقم سالب.
  • round(2.5) أعطت 3 وround(-2.5) أعطت -3، أي أن التقريب الافتراضي في PHP نصف بعيداً عن الصفر Half Away From Zero، بينما round(2.5) في Python تعطيك 2 لأنها تقرب إلى الزوجي Banker's Rounding. فإذا نقلت حساباً من Python إلى PHP ستختلف النتائج في كل حالة نصف بالضبط، والتفاصيل وخيارات التقريب الأخرى في توثيق round.

تجربة الضريبة VAT على مئة ألف سعر

والآن نعود إلى الشكوى الأولى: علبة الهيل بسعر 1.50 ريال وضريبتها 22 هللة. كتبنا الملف money.php الذي يحسب الضريبة بطريقتين لكل سعر من هللة واحدة حتى ألف ريال، أي مئة ألف سعر: الطريقة الأولى كما يكتبها أغلب من يبدأ، بالريال كرقم عشري ثم round لخانتين، والطريقة الثانية بالهللة كرقم صحيح مع التقريب نصف للأعلى بأرقام صحيحة فقط، وهي نفس دالة المقال الأول:

function vatFloat(float $riyals): float
{
    return round($riyals * 0.15, 2);
}
function vatInt(int $halalas): int
{
    return intdiv($halalas * 150 + 500, 1000);
}

$diff = 0;
$examples = [];
$total = 0;
for ($h = 1; $h <= 100_000; $h++) {      // every price from 0.01 to 1000.00 SAR
    $total++;
    $f = (int) round(vatFloat($h / 100) * 100);
    $i = vatInt($h);
    if ($f !== $i) {
        $diff++;
        if (count($examples) < 5) {
            $examples[] = sprintf('%.2f SAR: float %d, int %d (exact %s)', $h / 100, $f, $i, bcmul((string) $h, '0.15', 3));
        }
    }
}

والمخرج سوف يكون كما يلي:

prices checked: 100000, disagreements: 1276
1.50 SAR: float 22, int 23 (exact 22.500)
3.10 SAR: float 46, int 47 (exact 46.500)
3.30 SAR: float 49, int 50 (exact 49.500)
4.10 SAR: float 61, int 62 (exact 61.500)
4.30 SAR: float 64, int 65 (exact 64.500)
9.90 x 3 = 29.700000000000003, vat float = 4.46, vat int = 446 halalas
0.1 + 0.2 = 0.30000000000000004
ثلاثة أرقام: مئة ألف سعر من 0.01 إلى 1000 ريال، و1276 سعراً أعطت فيه الطريقتان ضريبة مختلفة، و1.28% من الأسعار كلها حالات نصف هللة بالضبط، وتحتها جدول لثلاثة أمثلة: 1.50 ريال ضريبتها الدقيقة 22.5 هللة أعطى الحساب بالريال 22 والحساب بالهللة 23، و3.10 ريال أعطى 46 و47، و4.30 ريال أعطى 64 و65
1,276 فاتورة من كل مئة ألف سعر تختلف بهللة واحدة عندما تحسب الضريبة بالريال العشري

في المخرج أعلاه لاحظ التالي:

  • اختلفت الطريقتان في 1,276 سعراً من مئة ألف، وتحققنا بسطر إضافي في التجربة أن كل هذه الحالات بلا استثناء هي الحالات التي تكون فيها الضريبة الدقيقة نصف هللة بالضبط، مثل 22.5. وفي المدى الذي جربناه يوجد 5,000 سعر من هذا النوع، أي أن الحساب بالريال أخطأ في ربعها تقريباً.
  • السبب أن 1.50 * 0.15 في الـ float ليست 0.225 بالضبط، وإنما رقم أصغر منها بقليل، فتقربه round إلى الأسفل، ولو كان أكبر بقليل لقربته إلى الأعلى، ولذلك لا تستطيع أن تتوقع أي الأسعار سوف تخطئ دون أن تجربها كلها.
  • العمود exact حسبناه بالدالة bcmul من إضافة BCMath التي تحسب بالنصوص العشرية بدقة كاملة، وهي الأداة الصحيحة عندما تحتاج كسوراً دقيقة مثل نسبة عمولة بثلاث خانات، ومنذ PHP 8.4 يوجد كلاس BcMath\Number يسمح بكتابة $a * $b عليها مباشرة.
  • مثال المسار كله، ثلاث عبوات قهوة بـ 9.90، أعطى نفس النتيجة بالطريقتين: 4.46 و446 هللة. أي أن اختبار حالة واحدة لم يكن ليكشف المشكلة، وهذا ما يجعلها تصل إلى الإنتاج.

والقاعدة التي تخرج بها من هذه التجربة: كل مبلغ مالي في PHP رقم صحيح int بأصغر وحدة، والتحويل إلى ريالات يكون فقط عند العرض، وإذا احتجت كسوراً لا تنتهي بالهللة فاستخدم BCMath ولا تستخدم float إطلاقاً. وحتى مع int بحجم 64 بت عندك حد أعلى يقارب 92 مليون مليار هللة، وهو أكبر من أي فاتورة سوف تراها.

ونفس حد الـ 64 بت يظهر بشكل أخطر عندما يأتي الرقم من نظام آخر داخل JSON، مثل رقم طلب من بوابة دفع أو معرف من نظام ERP، لأن json_decode تحوله إلى float إذا تجاوز PHP_INT_MAX دون أي تحذير. وجربنا ذلك في الملف bigint.php برقم من عشرين خانة:

$json = '{"external_id": 92233720368547758070, "qty": 3}';
var_dump(json_decode($json, true)['external_id']);
var_dump(json_decode($json, true, flags: JSON_BIGINT_AS_STRING)['external_id']);

والمخرج:

float(9.223372036854776E+19)
string(20) "92233720368547758070"

في السطر الأول ضاعت آخر أربع خانات من الرقم، ولو حفظته في قاعدة البيانات ثم بحثت به عن الطلب لما وجدته. والخيار JSON_BIGINT_AS_STRING يبقيه نصاً كما هو، وهذه هي القاعدة لكل معرف يأتي من خارج نظامك: المعرف نص وليس رقماً، حتى لو كان كل ما فيه أرقاماً، لأنك لا تجري عليه أي عملية حسابية.

النصوص الرقمية Numeric Strings والتحويل Casting

لأن PHP ولد ليعالج النماذج Forms، وكل ما يأتي من النموذج نص، فقد بنيت اللغة على فكرة النص الرقمي Numeric String: نص يشبه الرقم يعامل كرقم في العمليات الحسابية والمقارنات. وهذه الفكرة مريحة في صفحة بسيطة ومربكة في نظام مالي، والقسم الثاني من types.php يبين حدودها:

var_dump((int) '12abc', (int) 'abc', (float) '1e3', is_numeric('1e3'), is_numeric(' 5'), is_numeric('5 '));
var_dump('abc' <=> 'abd', [1, 2] == [2 => 1], '1e3' == '1000');

والمخرج:

int(12)
int(0)
float(1000)
bool(true)
bool(true)
bool(true)
int(-1)
bool(false)
bool(true)

في المخرج أعلاه لاحظ التالي:

  • التحويل الصريح (int) لا يفشل أبداً: '12abc' تصبح 12، و'abc' تصبح صفراً. لذلك لا تستخدم (int) $_POST['qty'] للتحقق من المدخلات، لأن كمية مكتوبة خطأ سوف تصبح صفراً أو رقماً آخر بصمت، واستخدم filter_var($v, FILTER_VALIDATE_INT) التي تعيد false عند أي نص غير صحيح.
  • is_numeric تقبل '1e3' والمسافة قبل الرقم وبعده، وهذا موثق في صفحة النصوص الرقمية، ولكنه يعني أن «رقمي» في PHP أوسع مما تتوقع.
  • العامل <=> Spaceship يعيد -1 أو 0 أو 1، وهو ما تحتاجه دوال الترتيب usort.
  • '1e3' == '1000' صحيحة، لأن النصين رقميان فيقارنان كرقمين، وهذا سبب آخر لـ === الذي ذكرناه في المقال الأول، وتغيير قواعد هذه المقارنات في PHP 8 موثق في مقترح Saner String to Number Comparisons.

التصريحات Type Declarations: من Union إلى never

منذ PHP 7.0 صارت الأنواع على المعاملات وقيم الإرجاع، ثم أضيفت كل سنة قطعة جديدة، والقسم الثالث من التجربة يجمع أهمها في دوال صغيرة من عالم المتجر:

interface HasPrice { public function price(): int; }
interface HasStock { public function stock(): int; }

function parseQty(int|string $raw): int
{
    return is_int($raw) ? $raw : (int) $raw;
}
function describe((HasPrice&HasStock)|null $item): string   // DNF type (PHP 8.2)
{
    return $item === null ? 'none' : "{$item->price()}/{$item->stock()}";
}
function fail(string $msg): never
{
    throw new RuntimeException($msg);
}
var_dump(parseQty(3), parseQty('4'), describe(new Item(990, 5)), describe(null));
try {
    parseQty(2.5);
} catch (TypeError $e) {
    echo 'TypeError: ', $e->getMessage(), "\n";
}

والمخرج سوف يكون كما يلي:

int(3)
int(4)
string(5) "990/5"
string(4) "none"
TypeError: parseQty(): Argument #1 ($raw) must be of type string|int, float given, called in /app/types.php on line 43

في الكود أعلاه لاحظ التالي:

  • int|string نوع متحد Union Type يقبل أحد النوعين، وهو مناسب للقيم التي تأتي من مصادر مختلفة، ولكن لاحظ أن الرسالة كتبته string|int بترتيب مختلف، لأن PHP يرتب الأنواع داخلياً.
  • (HasPrice&HasStock)|null صيغة DNF أي Disjunctive Normal Form، وهي تقاطع Intersection بين واجهتين أو null، أي «أي كائن له سعر ومخزون معاً، أو لا شيء». وهي من مقترح DNF Types في PHP 8.2.
  • never نوع إرجاع لدالة لا تعود إطلاقاً، فهي إما ترمي استثناء أو تنهي البرنامج، وفائدته أن PHPStan يعرف أن الكود بعد استدعائها لن ينفذ.
  • parseQty(2.5) فشلت رغم أن الدالة تقبل نوعين، لأن الوضع الصارم لا يحول float إلى int أو string.

الخصائص ذات النوع Typed Properties: غير مهيأة وليست null

والقسم الرابع يوضح نقطة تفاجئ القادم من Java أو C#، حيث كل حقل له قيمة افتراضية، أو من JavaScript حيث الحقل غير الموجود undefined:

final class Order
{
    public int $id;
    public ?string $note = null;
}
$o = new Order();
var_dump($o->note);
try {
    echo $o->id;
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

والمخرج:

NULL
Error: Typed property Order::$id must not be accessed before initialization

والخاصية $id لها نوع ولا قيمة افتراضية لها، فهي في حالة خاصة اسمها غير مهيأة Uninitialized، وهي ليست null لأن نوعها لا يقبل null أصلاً. وقراءتها قبل أن تسند لها قيمة ترمي Error، وهذا أفضل من أن تعطيك صفراً أو null يتسرب إلى قاعدة البيانات. وفي المقابل، الخاصية بدون نوع تبدأ بـ null تلقائياً كما كانت في PHP القديمة، وهذا من الأسباب التي تجعلك تكتب النوع على كل خاصية، والتفاصيل في توثيق الخصائص.

التعداد Enum: from أم tryFrom؟

والحالة التي يعيش فيها الطلب في المتجر مثال مناسب للتعداد المدعوم بقيمة Backed Enum، حيث تحفظ القيمة النصية في قاعدة البيانات ويتعامل الكود مع نوع حقيقي:

enum Status: string
{
    case New = 'new';
    case Paid = 'paid';
}
var_dump(Status::from('paid'), Status::tryFrom('refunded'));
try {
    Status::from('refunded');
} catch (ValueError $e) {
    echo 'ValueError: ', $e->getMessage(), "\n";
}

والمخرج:

enum(Status::Paid)
NULL
ValueError: "refunded" is not a valid backing value for enum Status

والفرق بين الدالتين هو نفس الفرق بين الخطأ والحالة المتوقعة: from ترمي ValueError عندما تكون القيمة غير معروفة، فاستخدمها عندما تقرأ من قاعدة بياناتك وتعرف أن القيمة يجب أن تكون صحيحة، وtryFrom تعيد null، فاستخدمها عندما تقرأ مدخلات من مستخدم أو من نظام خارجي وتريد أن ترد بخطأ 400 بنفسك.

الأنواع العامة Generics: موجودة في PHPStan فقط

إذا جئت من Java أو C# أو TypeScript فأول ما تبحث عنه هو List<int>، وPHP لا يدعم الأنواع العامة وقت التشغيل، ونوقشت الفكرة في عدة مقترحات دون أن تصل، والسبب أن فحصها في كل استدعاء مكلف في محرك يفحص الأنواع وقت التشغيل. والحل الذي اتفق عليه المجتمع هو أن تكتب الأنواع العامة في تعليقات PHPDoc، ويفهمها PHPStan وPsalm، كما يشرح دليل Generics في PHPStan:

/**
 * @template T
 */
final class TypedList
{
    /** @var list<T> */
    private array $items = [];

    /** @param T $item */
    public function add(mixed $item): void
    {
        $this->items[] = $item;
    }

    /** @return T|null */
    public function first(): mixed
    {
        return $this->items[0] ?? null;
    }
}

/** @var TypedList<int> $prices */
$prices = new TypedList();
$prices->add(990);
$prices->add('4500');          // runs fine: no check at runtime
echo cheapest($prices), "\n";

وعند التشغيل طبع الملف 990 دون أي خطأ، رغم أننا أضفنا نصاً إلى قائمة أرقام. أما PHPStan بالمستوى max فأعطى:

 ------ -----------------------------------------------------------------------
  Line   generics.php
 ------ -----------------------------------------------------------------------
  37     Parameter #1 $item of method TypedList<int>::add() expects int, strin
         g given.
 ------ -----------------------------------------------------------------------
 [ERROR] Found 1 error

وهنا ترى حدود نظام الأنواع في PHP بوضوح: ما تكتبه في الكود نفسه يفحص في كل استدعاء، وما تكتبه في التعليقات لا يراه PHP أبداً، وإنما يراه PHPStan فقط. لذلك يصبح تشغيل PHPStan في CI جزءاً من نظام الأنواع، وليس أداة إضافية تختارها حسب ذوقك، وهذا يشبه وضع Python مع mypy أكثر مما يشبه Java.

من يحرر الذاكرة Memory Management في PHP؟

الآن ننتقل إلى الشكوى الثالثة، الـ Worker الذي تكبر ذاكرته، ولكن قبلها نحتاج أن نفهم كيف يحرر PHP الذاكرة أصلاً. وPHP يستخدم آليتين معاً: عداد المراجع Reference Counting كآلية رئيسية، وجامع الدورات Cycle Collector كآلية مساعدة للحالة الوحيدة التي يفشل فيها العداد. وهذا يختلف عن Java وGo وNode.js التي تعتمد على جامع قمامة Garbage Collector يتتبع الكائنات الحية Tracing GC، ويقترب من Python التي تستخدم نفس الفكرة تقريباً، ومن ARC في Swift مع فرق أن Swift لا تملك جامعاً للدورات أصلاً.

عداد المراجع Reference Counting: التحرير الفوري

كل نص أو مصفوفة أو كائن له عداد، يزيد عندما يشير إليه متغير جديد، وينقص عندما يحذف المتغير أو يخرج من نطاقه، وعندما يصل إلى صفر تحرر الذاكرة فوراً ويستدعى __destruct إذا كان كائناً. والقسم الأول من الملف memory.php يبين ذلك بكائن يطبع رسالة عند إنشائه وعند تحريره:

$a = new Node('demo-A', true);
$b = $a;
echo "  unset(\$a)\n";
unset($a);
echo "  unset(\$b)\n";
unset($b);
echo "  after unset\n";

والمخرج سوف يكون كما يلي:

== 1. refcount: freed the moment the last reference goes
  construct demo-A
  unset($a)
  unset($b)
  destruct demo-A
  after unset

لاحظ أن unset($a) لم يحرر شيئاً، لأن $b ما زال يشير إلى الكائن، وأن destruct demo-A ظهر مباشرة بعد unset($b) وقبل السطر التالي. وهذا التحرير المحدد التوقيت Deterministic مفيد جداً: الملف الذي فتحه كائن يغلق في __destruct لحظة انتهاء استخدامه، وليس عندما يقرر جامع القمامة في Java أن يعمل بعد ثوان.

الدورات Cycles وجامعها

والمشكلة في عداد المراجع أنه لا يرى الدورات: كائنان يشير كل منهما إلى الآخر، مثل طلب يحمل قائمة أسطره وكل سطر يحمل مرجعاً إلى طلبه. وعندما تحذف آخر متغير يشير إليهما يبقى عداد كل واحد منهما 1، لأن الآخر يشير إليه، فلا يحرران أبداً بالعداد وحده:

$x = new Node('demo-X', true);
$y = new Node('demo-Y', true);
$x->other = $y;
$y->other = $x;
unset($x, $y);
echo "  after unset (no destruct yet)\n";
$freed = gc_collect_cycles();
echo "  gc_collect_cycles() freed {$freed} objects\n";

والمخرج:

== 2. a cycle is not freed by refcount
  construct demo-X
  construct demo-Y
  after unset (no destruct yet)
  destruct demo-X
  destruct demo-Y
  gc_collect_cycles() freed 2 objects
صندوقان: الأول كائن demo-A يشير إليه المتغيران $a و$b والعداد 2، وبعد حذف الاثنين يصبح صفراً ويحرر فوراً، والثاني كائنان demo-X وdemo-Y يشير كل منهما إلى الآخر والعداد 1 لكل منهما بعد حذف المتغيرات، فيبقيان حتى يعمل جامع الدورات بعد 10,000 جذر أو عند gc_collect_cycles
العداد يحرر فوراً، والدورات تنتظر جامعها

وجامع الدورات لا يعمل باستمرار، وإنما يحفظ في مخزن الجذور Root Buffer كل قيمة نقص عدادها دون أن يصل إلى صفر، لأنها قد تكون جزءاً من دورة، وعندما يمتلئ المخزن بـ 10,000 جذر محتمل يبدأ الخوارزمية التي تبحث عن الدورات وتحررها، كما يشرح توثيق جمع الدورات. ويمكنك أن تستدعيه بنفسك بـ gc_collect_cycles()، وأن ترى إحصاءاته بـ gc_status.

القياس: مئتا ألف كائن مع الجامع وبدونه

حتى نرى ثمن الدورات بالأرقام كتبنا الملف cycles.php، والذي ينشئ مئة ألف زوج من الكائنات، كل كائن يحمل نصاً بحجم كيلوبايت، وكل زوج يشير أحدهما إلى الآخر، ثم يطبع الزمن والذاكرة وإحصاءات الجامع، ويقبل معاملاً on أو off لتشغيل الجامع أو إيقافه بـ gc_disable():

$enabled = ($argv[1] ?? 'on') === 'on';
$enabled ? gc_enable() : gc_disable();
$start = memory_get_usage();
$t = hrtime(true);
for ($i = 0; $i < 100_000; $i++) {
    $p = new Node("p{$i}");
    $q = new Node("q{$i}");
    $p->other = $q;
    $q->other = $p;
}
unset($p, $q);

وشغلناه ثلاث مرات بكل وضع، مع رفع حد الذاكرة في وضع الإيقاف إلى 512 ميجابايت لسبب سوف تراه بعد قليل:

for i in 1 2 3; do php cycles.php on; php -d memory_limit=512M cycles.php off; done

والمخرج سوف يكون كما يلي:

gc on  memory_limit 128M  time  393.1 ms  memory now +0.2 MB  peak 14.0 MB  gc runs 20  collected 199960
gc off memory_limit 512M  time 1054.9 ms  memory now +270.5 MB  peak 271.0 MB  gc runs 0  collected 0
gc on  memory_limit 128M  time  450.0 ms  memory now +0.2 MB  peak 14.0 MB  gc runs 20  collected 199960
gc off memory_limit 512M  time  626.2 ms  memory now +270.5 MB  peak 271.0 MB  gc runs 0  collected 0
gc on  memory_limit 128M  time  498.5 ms  memory now +0.2 MB  peak 14.0 MB  gc runs 20  collected 199960
gc off memory_limit 512M  time  253.6 ms  memory now +270.5 MB  peak 271.0 MB  gc runs 0  collected 0
ثلاثة أشرطة: ذروة الذاكرة مع الجامع 14 ميجابايت، وبدونه 271 ميجابايت، وحد الذاكرة الافتراضي 128 ميجابايت، وتحتها ثلاث بطاقات: الجامع عمل 20 مرة وحرر 199,960 كائناً، وبدون الجامع مع الحد الافتراضي توقف البرنامج برسالة Allowed memory size exhausted، والزمن بين 273 و631 مللي ثانية مع الجامع وبين 228 و1055 بدونه
الفرق الحقيقي في الذاكرة وليس في الزمن: 14 ميجابايت مقابل 271

في المخرج أعلاه لاحظ التالي:

  • مع الجامع لم تتجاوز الذروة 14 ميجابايت، وعمل الجامع 20 مرة بالضبط، وهذا يطابق ما في التوثيق: كل كائن من المئتي ألف يدخل المخزن كجذر محتمل عندما ينقص عداده دون أن يصل إلى صفر، ومئتا ألف مقسومة على 10,000 تعطي 20.
  • جمع 199,960 كائناً وليس 200,000، لأن آخر الأزواج ما زالت في المخزن ولم يمتلئ بعد، وتحرر عند نهاية البرنامج.
  • بدون الجامع بقيت 270.5 ميجابايت في الذاكرة بعد انتهاء الحلقة، أي كل الكائنات تقريباً، رغم أن الكود لم يعد يشير إلى أي واحد منها.
  • الزمن في هذه التشغيلات الثلاث ومعها تسع تشغيلات سابقة محفوظة في الملف cycles-earlier-runs.txt كان بين 273 و631 مللي ثانية مع الجامع، وبين 228 و1055 بدونه، والجهاز كان يشغل أعمالاً أخرى في نفس الوقت، لذلك لا نستطيع أن نقول من هذه الأرقام إن أحد الوضعين أسرع، والنتيجة الواضحة الوحيدة هي الذاكرة.

وقد يتساءل البعض: إذا كان الجامع يعمل تلقائياً، فلماذا أهتم؟ والإجابة أن الطلب العادي في PHP يحذف كل شيء في نهايته كما رأينا في نموذج Share-Nothing، فالدورات فيه لا تضر غالباً، أما سكربت يستورد ملف منتجات فيه مليون سطر، أو Worker يعمل أياماً، فالدورات فيه تتراكم بين مرات عمل الجامع، وكل مرة يعمل فيها يمر على عشرة آلاف جذر. والحل أن تتجنب الدورات في التصميم من البداية، مثلاً بأن يحفظ السطر رقم الطلب بدل الطلب نفسه، أو أن تستخدم WeakReference للمرجع العكسي.

الكاش الضعيف WeakMap: لا يبقي الكائن حياً

ومن الأخطاء الشائعة أن تحفظ معلومات إضافية عن كائنات في مصفوفة كاش، فتبقى الكائنات حية لأن الكاش يشير إليها حتى بعد أن ينتهي استخدامها. والكلاس WeakMap منذ PHP 8.0 يحل ذلك، فهو يستخدم الكائن مفتاحاً دون أن يزيد عداده، وعندما يحرر الكائن يختفي مدخله من الكاش تلقائياً:

== 3. WeakMap: a cache that does not keep objects alive
  WeakMap entries while referenced: 3
  WeakMap entries after dropping references: 0

وهذا النمط مفيد عندما تحتاج أن تربط بيانات محسوبة بكائنات لا تملكها، مثل كيانات Entities من مكتبة ORM.

حد الذاكرة memory_limit

في أول تشغيل لتجربة الدورات لم نرفع حد الذاكرة، فتوقف وضع الإيقاف بهذه الرسالة:

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /app/cycles.php on line 13
Stack trace:
#0 /app/cycles.php(13): str_repeat('x', 1024)
#1 {main}

والرقم 134217728 بايت هو 128 ميجابايت، وهو القيمة الافتراضية للإعداد memory_limit، وهو حد لكل عملية PHP وليس للسيرفر كله. وهذا الحد هو خط الدفاع الذي يمنع طلباً واحداً سيئاً من أن يأخذ ذاكرة السيرفر كلها، لذلك لا ترفعه إلى -1 (بدون حد) في إعدادات الويب لأن سكربتاً واحداً احتاج أكثر، وإنما ارفعه لذلك السكربت فقط بـ php -d memory_limit=512M أو ini_set في أوله. ولاحظ أن هذا الخطأ من نوع Fatal Error الذي لا يمكن التقاطه بـ try، وسوف نعود لذلك في قسم الأخطاء.

ولاحظ الرقم في الرسالة: tried to allocate 2097152 bytes، أي 2 ميجابايت بالضبط، رغم أن الكائن الذي نصنعه كيلوبايت واحد فقط. والسبب أن مدير الذاكرة في PHP لا يطلب الذاكرة من نظام التشغيل كائناً كائناً، وإنما يحجزها بقطع Chunks حجم كل منها 2 ميجابايت، ثم يقسمها داخلياً على القيم الصغيرة، فالرسالة تذكر القطعة التي فشل حجزها وليس الكائن. ولهذا أيضاً تعطيك memory_get_usage(true) رقماً أكبر من memory_get_usage(): الأولى تعد القطع المحجوزة من النظام، والثانية تعد ما تستخدمه القيم داخلها فعلاً، وهي التي استخدمناها في كل القياسات.

ولهذا إذا رأيت في سجل الإنتاج رسالة Allowed memory size فلا تبدأ برفع الحد، وإنما ابحث أولاً عن الحلقة أو الاستعلام الذي حمل بيانات أكثر مما يحتاج.

العمليات الطويلة Long-Running Workers: لماذا تكبر الذاكرة؟

نعود الآن إلى الشكوى الثالثة. في المتجر عملية تعمل في الخلفية بالأمر php artisan queue:work تعالج الطلبات الجديدة وترسل رسائل التأكيد، وهي عملية واحدة تعمل أياماً، أي أنها لا تحصل على «البداية النظيفة» التي يحصل عليها كل طلب ويب. وكتبنا الملف worker.php الذي يحاكي ذلك بكلاس يحفظ سعر كل طلب في كاش ثابت static حتى لا يحسبه مرتين:

final class PriceLookup
{
    /** @var array<string, int> */
    private static array $cache = [];

    public static function priceFor(string $orderRef): int
    {
        return self::$cache[$orderRef] ??= random_int(100, 10_000);   // cached per order: grows forever
    }
}

$limitMb = (int) ($argv[1] ?? 0);
for ($job = 1; $job <= 200_000; $job++) {
    PriceLookup::priceFor("order-{$job}");
    if ($job % 50_000 === 0) {
        printf("job %6d  memory %5.1f MB\n", $job, memory_get_usage() / 1048576);
    }
    if ($limitMb > 0 && memory_get_usage() > $limitMb * 1048576) {
        printf("job %6d  memory over %d MB: exit so the supervisor starts a fresh process\n", $job, $limitMb);
        exit(0);
    }
}

والمخرج بدون حد ثم مع حد 8 ميجابايت:

job  50000  memory   4.9 MB
job 100000  memory   9.3 MB
job 150000  memory  16.2 MB
job 200000  memory  18.1 MB
job  50000  memory   4.9 MB
job  66985  memory over 8 MB: exit so the supervisor starts a fresh process
أعمدة تبين ذاكرة الـ Worker بعد 50 ألف مهمة 4.9 ميجابايت، ثم 9.3 بعد مئة ألف، ثم 16.2، ثم 18.1 بعد مئتي ألف مهمة، وتحتها صندوقان: السبب كاش ثابت يحفظ سعراً لكل طلب ولا يحذف شيئاً، والعلاج حد للذاكرة يخرج بعده الـ Worker عند المهمة 66,985 ويعيد المشرف تشغيله
في الـ Worker لا يوجد نهاية طلب تحذف الكاش، فيكبر مع كل مهمة

وهذه ليست دورة يحلها الجامع، وإنما كاش يحتفظ بكل شيء عن قصد، فلا يوجد أي آلية في PHP سوف تحرره. والعلاج له طبقتان: الأولى في الكود، بأن يكون للكاش حجم أقصى أو مدة، أو أن يفرغ بعد كل مهمة، والثانية في التشغيل، بأن يخرج الـ Worker بشكل نظيف عندما يتجاوز حداً معيناً من الذاكرة أو عدداً من المهام، ويعيد تشغيله مشرف Supervisor مثل systemd أو supervisord. وهذه بالضبط فكرة الخيارين --memory و--max-jobs في طوابير Laravel. وتذكر أن نفس المشكلة تظهر في أي وضع يبقي التطبيق في الذاكرة، مثل FrankenPHP في وضع Worker وLaravel Octane، فهناك كل static وكل Singleton يعيش بين الطلبات.

الأخطاء Errors في PHP: ثلاثة أنواع من الفشل

بقيت الشكوى الثانية: الطلب الذي وصل فارغاً دون أي خطأ في السجلات. وجذرها أن PHP فيه ثلاثة أنواع من الفشل، وليس نوعاً واحداً كما في Java أو Python، والنوع الأول منها لا يوقف الكود أصلاً:

  • التحذير Warning والتنبيه Notice والإهمال Deprecated: رسالة تكتب في السجل أو على الشاشة، ثم يكمل الكود بقيمة مثل false أو null. وهذا هو الإرث الأقدم في اللغة، وما زالت دوال كثيرة في المكتبة القياسية تعمل به.
  • الخطأ Error: يرمى عند خلل في الكود نفسه، مثل نوع خاطئ أو استدعاء دالة على null أو قسمة على صفر، وهو قابل للالتقاط منذ PHP 7 ولكنه ليس مخصصاً لذلك.
  • الاستثناء Exception: يرمى عند فشل متوقع يمكن أن يتعامل معه البرنامج، مثل JSON غير صالح أو منتج نفد من المخزون.

والاثنان الأخيران يطبقان الواجهة Throwable، أما الأول فليس كائناً أصلاً.

شجرة تبدأ من Throwable وتتفرع إلى Error وتحته TypeError وValueError وArithmeticError ثم DivisionByZeroError، وإلى Exception وتحته RuntimeException وLogicException ثم DomainException ثم OutOfStock، وJsonException وErrorException، وصندوق ثالث للتحذير Warning الذي ليس استثناء، مع مثال file_get_contents التي تعيد false وjson_decode التي تعيد NULL بدون أي تحذير
التحذير ليس استثناء، والكود يكمل بعده بقيمة false أو null

التحذير Warning: الكود يكمل بقيمة خاطئة

والقسم الأول من الملف errors.php يعيد إنتاج شكوى الطلب الفارغ بالضبط: نقرأ ملفاً غير موجود، ثم نحلل JSON من تطبيق الجوال فيه قوس ناقص:

$body = file_get_contents('/app/missing-order.json');
var_dump($body);
$order = json_decode('{"items": [{"product_id": 1, "qty": 3}', true);   // missing closing brace
var_dump($order, json_last_error_msg());

والمخرج سوف يكون كما يلي:

Warning: file_get_contents(/app/missing-order.json): Failed to open stream: No such file or directory in /app/errors.php on line 8
bool(false)
NULL
string(12) "Syntax error"

في المخرج أعلاه لاحظ التالي:

  • file_get_contents طبعت تحذيراً وأعادت false، والكود أكمل إلى السطر التالي، ولو لم نتحقق من false لمررناها إلى دالة أخرى تتوقع نصاً.
  • json_decode أسوأ من ذلك: أعادت NULL دون أن تطبع أي تحذير، والطريقة الوحيدة لمعرفة السبب هي أن تسأل json_last_error_msg() بعدها. وهذا بالضبط ما حدث في المتجر: الكود قرأ $order['items'] ?? [] فأصبحت قائمة الأسطر فارغة، وحفظ طلباً فارغاً.

والإصلاح له طريقتان، وكلاهما في القسم الثاني من التجربة. الأولى للدوال التي تدعم ذلك، وهي أن تطلب منها أن ترمي استثناء، مثل الخيار JSON_THROW_ON_ERROR الموجود منذ PHP 7.3 كما في توثيق json_decode. والثانية لكل الدوال الأخرى، وهي معالج أخطاء عام يحول كل تحذير إلى ErrorException باستخدام set_error_handler:

try {
    json_decode('{"items": [{"product_id": 1, "qty": 3}', true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    echo get_class($e), ': ', $e->getMessage(), ' (code ', $e->getCode(), ")\n";
}
set_error_handler(function (int $no, string $msg, string $file, int $line): bool {
    throw new ErrorException($msg, 0, $no, $file, $line);
});
try {
    file_get_contents('/app/missing-order.json');
} catch (ErrorException $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
restore_error_handler();

والمخرج:

JsonException: Syntax error (code 4)
ErrorException: file_get_contents(/app/missing-order.json): Failed to open stream: No such file or directory

وأطر العمل الحديثة مثل Laravel وSymfony تسجل معالجاً مثل هذا تلقائياً، فتتحول التحذيرات إلى استثناءات في بيئة التطوير، لذلك قد لا تلاحظ المشكلة إلا عندما تكتب سكربتاً مستقلاً بدون إطار. والقاعدة: في أي كود جديد خارج إطار عمل، سجل معالج الأخطاء في أول سطر، واستخدم JSON_THROW_ON_ERROR في كل استدعاء لـ json_decode وjson_encode.

الخطأ Error: أخطاء البرمجة ترمى أيضاً

والقسم الثالث يشغل ست حالات من أخطاء البرمجة ويطبع نوع كل واحدة ورسالتها:

== 3. Error: programming mistakes are thrown too
  intdiv by zero    DivisionByZeroError    Division by zero
  modulo by zero    DivisionByZeroError    Modulo by zero
  division by zero  DivisionByZeroError    Division by zero
  negative repeat   ValueError             str_repeat(): Argument #2 ($times) must be greater than or equal to 0
  undefined method  Error                  Call to undefined method ArrayObject::total()
  null method       Error                  Call to a member function total() on null

وقبل PHP 8 كانت القسمة 10 / 0 تعطي تحذيراً وتعيد INF، وstr_repeat('x', -1) تعطي تحذيراً وتعيد null، أما الآن فكل منها يرمي خطأ يوقف الكود إذا لم يلتقط. وValueError جديد في PHP 8.0، وهو للحالة التي يكون فيها النوع صحيحاً والقيمة غير مقبولة. وهذه الأخطاء لا تلتقطها لتكمل كأن شيئاً لم يحدث، وإنما يلتقطها معالج عام واحد في أعلى التطبيق ليكتبها في السجل ويرد بخطأ 500، ثم تصلح الكود.

الاستثناءات Exceptions الخاصة بالمتجر: previous وfinally

أما الفشل المتوقع في منطق المتجر، مثل نفاد المخزون، فله استثناء خاص يحمل البيانات التي يحتاجها من يلتقطه، والقسم الخامس من التجربة يبين ثلاث أدوات معاً: الاستثناء الخاص، والاستثناء السابق Previous الذي يحفظ السبب الأصلي، وfinally الذي ينفذ في كل الحالات:

final class OutOfStock extends DomainException
{
    public function __construct(public readonly int $productId, public readonly int $available, ?Throwable $previous = null)
    {
        parent::__construct("product {$productId}: only {$available} left", 409, $previous);
    }
}
function reserve(int $productId, int $qty): void
{
    try {
        echo "  open transaction\n";
        if ($qty > 2) {
            throw new OutOfStock($productId, 2, new RuntimeException('stock check failed'));
        }
        echo "  commit\n";
    } finally {
        echo "  finally: release lock\n";
    }
}

والمخرج:

== 5. domain exceptions, previous, finally
  open transaction
  finally: release lock
  caught OutOfStock code=409 available=2: product 1: only 2 left
  previous: stock check failed

في الكود والمخرج أعلاه لاحظ التالي:

  • finally نفذ قبل أن يصل الاستثناء إلى catch في الدالة التي استدعت reserve، ولذلك هو المكان الصحيح لتحرير القفل أو إغلاق الملف.
  • الاستثناء يحمل available=2 كخاصية readonly، فيستطيع من يلتقطه أن يكتب رسالة واضحة للعميل دون أن يحلل نص الرسالة.
  • رمز 409 في code مجرد رقم نختاره، ولكنه سوف يصبح رد HTTP 409 Conflict في خدمة الطلبات في المقال الرابع.
  • الاستثناء السابق getPrevious() يحفظ سلسلة الأسباب، وتطبعه أطر العمل كاملاً في السجل، فلا تفقد السبب الأصلي عندما تحول استثناء منخفض المستوى إلى استثناء من منطق المتجر.

السمة NoDiscard في PHP 8.5: لا تتجاهل النتيجة

بعض الدوال لا ترمي استثناء عند الفشل، وإنما تعيد false، ومشكلتها أن من يستدعيها قد يتجاهل النتيجة. والسمة #[\NoDiscard] الجديدة في PHP 8.5 تجعل PHP يطبع تحذيراً إذا استدعيت الدالة وتجاهلت ما أعادته:

#[\NoDiscard('the result says whether the stock was reserved')]
function tryReserve(int $qty): bool
{
    return $qty <= 2;
}
tryReserve(5);
$ok = tryReserve(1);
(void) tryReserve(1);

والمخرج:

Warning: The return value of function tryReserve() should either be used or intentionally ignored by casting it as (void), the result says whether the stock was reserved in /app/errors.php on line 88
  ok=true

والاستدعاء الأول وحده هو الذي أعطى التحذير، أما الثاني فاستخدم النتيجة، والثالث تجاهلها صراحة بالتحويل (void) الجديد أيضاً في 8.5، وهو طريقتك لتقول للقارئ «أعرف أن هناك نتيجة ولا أحتاجها».

ما لا يلتقط Uncaught: رمز الخروج 255

وفي آخر الملف رمينا OutOfStock دون أن نلتقطه:

Fatal error: Uncaught OutOfStock: product 2: only 0 left in /app/errors.php:94
Stack trace:
#0 {main}
  thrown in /app/errors.php on line 94
exit=255

ورمز الخروج 255 هو ما تراه أنظمة التشغيل وأدوات CI، وهو يختلف عن الصفر الذي رأيناه في المقال الأول عندما أعطى PHP تحذيراً وأكمل. وهناك أخطاء قاتلة لا تمر عبر Throwable إطلاقاً، مثل تجاوز memory_limit الذي رأيناه، وتجاوز max_execution_time، والطريقة الوحيدة لتسجيلها هي دالة تسجل بـ register_shutdown_function وتقرأ error_get_last()، وهذا ما تفعله أطر العمل لتسجل هذه الحالات في السجل.

إعدادات الأخطاء Error Configuration بين التطوير والإنتاج

بقي سؤال مهم يفسر لماذا لم يظهر الطلب الفارغ في سجلات المتجر: أين تذهب التحذيرات أصلاً؟ والجواب في أربعة إعدادات في php.ini، والصورة الرسمية تأتي بنموذجين لهما قيم مختلفة، فقارناهما مباشرة:

for f in development production; do echo "-- $f"; grep -E "^(display_errors|display_startup_errors|error_reporting|log_errors|zend.exception_ignore_args|zend.assertions|memory_limit)" /usr/local/etc/php/php.ini-$f; done

والمخرج سوف يكون كما يلي:

-- development
zend.exception_ignore_args = Off
memory_limit = 128M
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
zend.assertions = 1
-- production
zend.exception_ignore_args = On
memory_limit = 128M
error_reporting = E_ALL & ~E_DEPRECATED
display_errors = Off
display_startup_errors = Off
log_errors = On
zend.assertions = -1

في المخرج أعلاه لاحظ التالي:

  • display_errors يعرض الأخطاء في الصفحة نفسها، وهو مفعل في التطوير ومغلق في الإنتاج، والسبب أن رسالة الخطأ تكشف مسارات الملفات وأحياناً أجزاء من الاستعلامات للمستخدم وللمخترق معاً. ولا تفعله في الإنتاج إطلاقاً حتى لدقيقة واحدة لتتبع مشكلة.
  • log_errors مفعل في الاثنين، وهو الذي يكتب الأخطاء في السجل، ومكانه يحدده error_log، وإذا لم تحدده يكتب PHP-FPM في سجل الـ Worker أو سجل السيرفر. وفي الـ Container ضعه على /dev/stderr حتى يظهر في docker logs، وقد جربنا ذلك فظهر التحذير بهذا الشكل: PHP Warning: file_get_contents(/nope): Failed to open stream مع الوقت بتوقيت UTC.
  • error_reporting في الإنتاج يستثني E_DEPRECATED، أي أن تحذيرات الإهمال مثل Implicit conversion from float 5.5 to int loses precision التي رأيناها في المقال الأول لن تصل إلى سجل الإنتاج أبداً. وهذه التحذيرات هي إنذارك المبكر بأن الإصدار القادم سوف يغير سلوكاً تعتمد عليه، لذلك يجب أن تراها في التطوير وفي CI، وأن يفشل CI عندما تظهر.
  • zend.exception_ignore_args مفعل في الإنتاج، فلا يحفظ قيم المعاملات في الـ Stack Trace، والسبب أن هذه القيم قد تكون باسوردات أو توكنات تصل إلى السجل. ولهذا ترى في سجلات الإنتاج أسماء الدوال دون قيمها.
  • zend.assertions = -1 في الإنتاج يحذف استدعاءات assert() من الكود عند الترجمة، فلا تكلف شيئاً، لذلك لا تضع في assert أي تحقق يجب أن يعمل في الإنتاج مثل التحقق من صلاحيات المستخدم، وإنما فقط افتراضات تريد أن تراها تفشل أثناء التطوير.

وقد يتساءل البعض: لماذا لم تحذرنا PHP عندما أعادت json_decode القيمة null؟ والإجابة أن هذه الدالة لا تطبع تحذيراً أصلاً، فلو كان المتجر يكتب كل التحذيرات في السجل لما ظهر شيء أيضاً. ولهذا الحل الصحيح ليس رفع مستوى السجلات، وإنما أن يتحول كل فشل إلى استثناء يمر عبر معالج واحد يسجله ويرد على العميل بخطأ واضح، والتفاصيل الكاملة لكل إعداد في توثيق إعدادات الأخطاء.

كيف تغير هذا كله من PHP 7 إلى 8.5؟

كل ما رأيته في هذا المقال جاء على مراحل، ومعرفة الإصدار الذي أضاف كل شيء تساعدك عندما تقرأ كوداً قديماً، أو عندما يكون مشروعك على إصدار أقدم من 8.5:

خط زمني بسبعة إصدارات: 7.0 أنواع المعاملات ونوع الإرجاع وstrict_types وتحول الأخطاء القاتلة إلى Error، و7.4 الخصائص ذات النوع والدالة fn، و8.0 الأنواع المتحدة وmixed وmatch وValueError، و8.1 enum وreadonly والتقاطع وnever، و8.2 كلاس readonly وDNF، و8.4 الخطافات والرؤية غير المتماثلة، و8.5 العامل |> والنسخ مع التعديل وNoDiscard
من 2015 إلى 2025 أضيفت قطعة من نظام الأنواع في كل إصدار تقريباً

والنتيجة العملية أن كوداً كتب لـ PHP 7.2 قد يكون صحيحاً تماماً ولكنه لا يستفيد من أي شيء في هذا المقال: خصائص بلا أنواع، وثوابت نصية بدل enum، وswitch بدل match، وتحذيرات بدل استثناءات. والأداة التي تساعدك في الترقية هي Rector، والتي تعيد كتابة الكود تلقائياً لصيغ الإصدار الأحدث، وأدلة الترقية الرسمية مثل دليل الترقية إلى 8.5 تذكر كل تغيير قد يغير سلوك كودك.

مشكلتان ظهرتا في التجربة Lab Problems

المشكلة الأولى هي رسالة Allowed memory size exhausted التي رأيتها في قسم حد الذاكرة. ففي الكتابة الأولى للتجربة وضعنا الوضعين، مع الجامع وبدونه، في نفس الملف ونفس العملية، فتوقف البرنامج كله عند الوضع الثاني ولم نحصل على أي رقم منه. والحل كان فصل كل وضع في عملية مستقلة cycles.php on وcycles.php off، ورفع الحد لوضع الإيقاف فقط من سطر الأوامر، وبقيت رسالة الحد الافتراضي في المخرجات كتجربة مستقلة. والدرس العام: عندما تقيس الذاكرة في PHP فشغل كل قياس في عملية جديدة، لأن العملية لا تعيد الذاكرة التي حجزتها إلى النظام بسهولة، والقياس الثاني يتأثر بالأول.

والمشكلة الثانية أطرف. كتبنا سطر جمع الدورات أول مرة كما يلي:

echo '  gc_collect_cycles() = ', gc_collect_cycles(), "\n";

فظهر المخرج مختلطاً بهذا الشكل:

  gc_collect_cycles() =   destruct demo-X
  destruct demo-Y
2

والسبب أن echo بعدة قيم مفصولة بفواصل يطبع كل قيمة بعد حسابها بالترتيب، فطبع النص الأول، ثم استدعى gc_collect_cycles() التي حررت الكائنين فطبع __destruct رسائله، ثم طبع الرقم 2. وهذا يذكرك بأن __destruct يعمل في لحظة التحرير مهما كان ما يحدث حوله، فلا تضع فيه شيئاً يعتمد على ترتيب المخرجات أو على حالة خارجية، وفصلنا السطر إلى متغير ثم طباعة.

ماذا يحدث إذا أوقفت جامع الدورات بالحد الافتراضي؟ What If

بعض المقالات القديمة تنصح بإيقاف جامع الدورات بـ gc_disable() أو zend.enable_gc=0 لتسريع السكربتات الطويلة، وكان لهذا النصح سبب في PHP 5.3 حيث كان الجامع أبطأ بكثير. فماذا يحدث إذا طبقته على نفس التجربة دون أن ترفع حد الذاكرة؟

php cycles.php off; echo "exit=$?"
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /app/cycles.php on line 13
Stack trace:
#0 /app/cycles.php(13): str_repeat('x', 1024)
#1 {main}
exit=255

والنتيجة أن السكربت الذي كان يكفيه 14 ميجابايت أصبح يحتاج أكثر من 128، ولم يكمل. ولم تظهر في قياسنا أي فائدة واضحة في الزمن تبرر ذلك، لذلك لا توقف الجامع إلا إذا قست بنفسك أن كودك لا يصنع دورات، وأن الزمن تحسن فعلاً على بياناتك.

أخطاء شائعة Common Mistakes

  • حساب المبالغ بـ float ثم round: رأيت أنه يخطئ في ربع حالات النصف هللة. احسب بالهللة int، أو بـ BCMath للنسب الدقيقة.
  • التحقق من الأرقام بالتحويل (int): لا يفشل أبداً، فاستخدم filter_var مع FILTER_VALIDATE_INT.
  • json_decode بدون JSON_THROW_ON_ERROR: تعيد null بصمت ويمر الطلب فارغاً.
  • تجاهل قيمة false من دوال الملفات والشبكة: سجل معالج أخطاء يحول التحذيرات إلى استثناءات.
  • التقاط Throwable أو Error في منتصف الكود للاستمرار: هذا يخفي أخطاء برمجة يجب إصلاحها، والتقط الاستثناءات المحددة فقط.
  • كتابة @ قبل استدعاء دالة لإسكات التحذير: يخفي السبب ولا يصلحه.
  • كاش static أو Singleton يكبر دون حد في Worker أو في Octane.
  • الاعتماد على Generics في PHPDoc دون تشغيل PHPStan في CI: لا أحد يفحصها غيره.
  • رفع memory_limit إلى -1 في إعدادات الويب لأن سكربتاً واحداً احتاج أكثر.

جدول القرار Decision Table: أي أداة لأي حالة؟

الحالةاستخدمولا تستخدم
مبلغ مالي بالريال والهللةint بالهللةfloat مع round
نسبة أو سعر صرف بكسور طويلةBCMath أو BcMath\Numberfloat
رقم من مدخلات المستخدمfilter_var(..., FILTER_VALIDATE_INT)(int) $value
قيمة من مجموعة ثابتةenum مع tryFrom للمدخلاتثوابت نصية
قائمة من نوع واحدlist<T> في PHPDoc مع PHPStanarray بلا وصف
فشل متوقع في منطق العملاستثناء خاص يرث من DomainException أو RuntimeExceptionإعادة false
دالة تعيد نتيجة مهمة#[\NoDiscard]تعليق يطلب من القارئ أن ينتبه
كاش لبيانات عن كائناتWeakMapمصفوفة عادية
Worker طويلحد للذاكرة وعدد المهام مع مشرف يعيد التشغيلإعادة تشغيل يدوية حين يشتكي أحد

تدريب عملي Practice

  • عدل money.php ليستخدم round($riyals * 0.15, 2, PHP_ROUND_HALF_EVEN)، وعد الفروق مرة أخرى، ثم فكر: أي القاعدتين تطلبها الجهة الضريبية التي تتعامل معها؟
  • غير Node في cycles.php ليحفظ المرجع العكسي في WeakReference بدل الكائن نفسه، وشغله مع إيقاف الجامع، وقارن الذاكرة.
  • أضف إلى worker.php حداً لحجم الكاش يحذف أقدم مدخل عندما يتجاوز عشرة آلاف، وارسم الذاكرة كل خمسين ألف مهمة.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • PHP ديناميكي الأنواع ويفحص الأنواع المكتوبة على الدوال والخصائص في كل استدعاء، أما Generics وأشكال المصفوفات فيفحصها PHPStan فقط.
  • الحساب بالريال العشري أخطأ في 1,276 ضريبة من مئة ألف سعر، كلها حالات نصف هللة، والحساب بالهللة الصحيحة لا يخطئ.
  • الذاكرة يحررها عداد المراجع فوراً، والدورات يحررها جامع يعمل كل 10,000 جذر، وبدونه احتاجت التجربة 271 ميجابايت بدل 14 وتوقفت عند حد 128.
  • الـ Worker لا يبدأ من الصفر مثل طلب الويب، وكل كاش ثابت فيه يكبر حتى يوقفه حد الذاكرة والمشرف.
  • التحذير ليس استثناء، وjson_decode تعيد null بصمت، فاستخدم JSON_THROW_ON_ERROR ومعالج أخطاء يحول التحذيرات إلى ErrorException.
  • Error لأخطاء الكود وException للفشل المتوقع، وfinally لتحرير الموارد، و#[\NoDiscard] في 8.5 للنتائج التي لا يجب تجاهلها.

وفي المقال التالي من المرحلة ننتقل إلى التزامن: كيف يخدم PHP طلبات كثيرة في نفس الوقت عبر عمليات PHP-FPM، وماذا تضيف Fibers والمكتبات غير المتزامنة، مع قياس لنفس المهمة بعدة طرق.

دليلPHP لمن يعرف البرمجة: الصيغة والأدوات Toolchain في يومدليل عملي للمطور القادم من Python أو JavaScript يثبت PHP 8.5 ويقارن صيغتها بما يعرفه ويشغل Composer وPint وPHPStan وPHPUnit على مشروع صغير بمخرجات حقيقية.دليلالتزامن Concurrency في PHP: عمليات FPM والألياف Fibersقسنا مئة طلب HTTP وأربعة ملايين عملية SHA-256 بعمليات PHP-FPM وFibers وAMPHP والتفرع والخيوط، ثم صنعنا حالة سباق على المخزون بين عمليات FPM وأصلحناها بالقفل.دليلخدمة حقيقية بـ PHP: Laravel مع PostgreSQL والاختباراتبنينا خدمة طلبات لمتجر صغير بـ Laravel 13 وPostgreSQL 17 بمعاملة تقفل المخزون، مع اختبار يرسل 20 طلباً متزامناً على 10 علب، ثم Docker Image بمرحلتين وقياس للأداء كشف ثمن الاتصال بقاعدة البيانات.دليلأخطاء شائعة وأسلوب PHP الحديث Modern PHPثلاثة عشر خطأ يقع فيها المطور القادم إلى PHP من لغة أخرى، كل خطأ بتشغيل حقيقي على PHP 8.5 ثم الإصلاح، مع ما كشفه PHPStan منها وخطة قراءة من المصادر الرسمية.دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب بـ PHP 8.5.12 وPHPStan 2.3.4 داخل Docker.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.