آشنایی با Generator ها در PHP و LazyCollection ها در لاراول

آشنایی با Generator ها در PHP و LazyCollection ها در لاراول

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

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

برای ساخت یک Collection عادی، کافی است آرایه ای از مقادیر را به آن بدهیم. برای مثال آرایه ای ساده از چند عدد را به Collection تبدیل می کنیم.

use Illuminate\Support\Collection;
new Collection([1, 2, 3, 4, 5]);

 Collection های لاراول متد جالبی به نام times دارند که راه آسانی برای ایجاد یک collection از چندین عدد را فراهم می کند.

Collection::times(100); // [1, 2, 3, ... 98, 99, 100]

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

Collection::times(100)  // [1, 2, 3, ... 98, 99, 100]
->map(fn ($number) => $number * 2)  // [2, 4, 6, ... 196, 198, 200]
->filter(fn ($number) => $number % 20 == 0); // [20, 40, 60, ... 160, 180, 200]

با اینکه این مثال ساده احتمالا در دنیای واقعی کاربردی ندارد اما حقیقت مهمی درمورد collection های عادی به ما نشان می دهد. همه مقادیر در مموری نگه داشته می شوند و هر متدی که روی collection صدا می زنیم، یک آرایه جدید به همراه یک شی جدید از کلاس Collection را در مموری ایجاد می کند.

تمام شدن حافظه مموری

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

برای اینکه این موضوع را بهتر نشان دهیم یک collection با یک میلیارد عضو از نوع عدد می سازیم.

Collection::times(1000 * 1000 * 1000);

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

Allowed memory size of 3154116608 bytes exhausted (tried to allocate 34359738376 bytes)

دلیل این خطا ساده است. متد times تلاش می کند collection ای بسازد که همه مقادیر آن در مموری ذخیره می شوند و تخصیص دادن مموری برای یک میلیارد عدد باعث می شود حجم مموری موجود به سرعت پر شود. همچنین اگر بخواهیم تنها با بخش کوچکی از این collection کار کنیم (به عنوان مثال 1000 عدد زوج را بگیریم) باز هم مموری پر می شود.

Collection::times(1000 * 1000 * 1000)
    ->filter(fn ($number) => $number % 2 == 0)
    ->take(1000);

این مسئله به این دلیل است که در هر مرحله یک collection جدید در مموری ساخته می شود. درواقع وقتی متد times را صدا می کنیم هیچ اطلاعی ندارد که قرار است مقادیر آن را فیلتر کنیم.

روی آوردن به LazyCollection ها

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

use Illuminate\Support\LazyCollection;

$collection = LazyCollection::times(1000 * 1000 * 1000)
    ->filter(fn ($number) => $number % 2 == 0)
    ->take(1000);

 در حقیقت این تکه کد، هیچ حافظه ای از مموری اشغال نمی کند. این اتفاق توسط Generator ها در PHP ممکن شده است. برای اینکه LazyCollection ها را به طور کامل متوجه شویم ابتدا نیاز است تا به دانش مناسبی از Generator های PHP برسیم.

توابع Generator در زبان PHP

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

توابع Generator می توانند چندین مقدار را برگردانند

توابع عادی در PHP تنها یک مقدار را می توانند بر گردانند و پس از برگرداندن اولین مقدار، تمام کد هایی که پس از عبارت return اند نادیده گرفته می شود.

function run() {
    return 1;
    return 2;
}
dump(run());

کد بالا تنها عدد 1 را dump می کند و پس از اولین return اجرای تابع به پایان می رسد.

برای اینکه چندین مقدار را از تابع برگردانیم از کلید واژه yield به جای return استفاده می کنیم.

function run() {
    yield 1;
    yield 2;
}
dump(run());

اگر تکه کد بالا را اجرا کنید احتمالا از نتیجه آن متعجب می شوید.

Generator {
    executing: {...}
    closed: false
}

نتیجه ی آن یک شی Generator است. اما در تابع run، ما هرگز شی ای با نام Generator نساختیم و یا آن را بر نگرداندیم، پس این شی از کجا آمده است؟

این همان چیزی است که به آن تابع Generator می گوییم. تنها وجود عبارت yield در یک تابع، به PHP دستور می دهد که این یک تابع عادی نیست و از نوع Generator است. در PHP با توابع Generator به شکل کاملا متفاوتی از توابع عادی رفتار می شود. این رفتار متفاوت را می توانیم با اضافه کردن یک dump به تابع run متوجه شویم.

function run() {
    dump('Did we get here?');
    yield 1;
    yield 2;
}
dump(run());

این تکه کد هیچ چیزی را dump نمی کند. اما دلیل این موضوع چیست؟

صدا زدن یک تابع Generator هیچ کدی از داخل بدنه آن را اجرا نمی کند و به جای آن یک شی Generator به ما می دهد. این شی دارای قابلیتی است که کد تابع را به ازای کلید واژه های yield که در آن وجود دارد به صورت گام به گام اجرا کند.

استفاده از متد های current و next برای گرفتن مقادیر توابع Generator

برای شروع پیمایش کد یک تابع Generator از متد current استفاده می کنیم. این کار باعث می شود کد های تابع تا اولین yield اجرا شوند و مقداری که yield شده بود برگردانده شوند.

function run() {
    yield 1;
    yield 2;
}
$generator = run();
$firstValue = $generator->current();
dump($firstValue);

با اجرای کد بالا مقدار 1 را خواهیم دید.

بنابر این از متد current در Generator استفاده می کنیم که اجرای تابع Generator مان تا اولین yield آغاز، و مقدار آن برگردانده شود.

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

این بار به جای dump کردن مقدار فعلی، تابع خود را به عبارت yield بعدی می بریم و تنها مقدار دوم را dump می کنیم.

function run() {
    yield 1;
    yield 2;
}
$generator = run();
$firstValue = $generator->current();
$generator->next();
$secondValue = $generator->current();

dump($secondValue);

این کد مقدار 2 را dump می کند که همان مقدار yield دوم است.

بنابراین از متد next هنگامی استفاده می کنیم که می خواهیم تابع Generator را تا عبارت yield بعدی جلو ببریم.

استفاده از yield در یک حلقه

تا کنون با چند عبارت yield که به صورت hard-code در یک تابع نوشته شده بود کار کردیم. اما قدرت واقعی yield هنگامی مشخص می شود که از آن در یک حلقه استفاده کنیم.

function generate_numbers()
{
    $number = 1;
    while (true) {
        yield $number;
        $number++;
    }
}
$generator = generate_numbers();
dump($generator->current()); // Dumps: 1

$generator->next();
dump($generator->current()); // Dumps: 2

$generator->next();
dump($generator->current()); // Dumps: 3

همانطور که در تکه کد بالا مشاهده می کنید یک حلقه بی نهایت را نوشته ایم. از آن جایی که هر گام از این حلقه به خودی خود اجرا نمی شود، این حلقه در واقع بی نهایت نیست و تنها به میزانی که ما از آن درخواست کنیم مقدار تولید می کند. (این کار را با استفاده از متد های current و next انجام می دهیم.)

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

استفاده از Generator ها در foreach

دائما صدا زدن متد های current و next برای پیمایش یکGenerator  می تواند خسته کننده باشد. به همین خاطر در PHP می توانیم Generator ها را مستقیما به حلقه‌ ی foreach بدهیم.

برای مثال 20 عدد اول تابع Generator ای که بالاتر نوشتیم را dump می کنیم.

$generator = generate_numbers();
foreach ($generator as $number) {
    dump($number);
    if ($number == 20) break;
}

به عبارت break در کد بالا دقت کنید. از آن جایی که در تابع Generator خود یک حلقه بی نهایت داریم، باید حواسمان باشد که تنها مقدار مشخصی از اعداد را از آن بیرون بکشیم. در غیر این صورت حلقه foreach ای که نوشتیم باعث ایجاد یک حلقه بی نهایت خواهد شد.

بنابراین می توانیم از foreach استفاده کنیم تا به راحتی روی تمام مقادیر تابع Generator خود پیمایش کنیم اما باید توجه داشته باشیم که  حلقه ی foreach را در یک مقطعی متوقف کنیم تا باعث ایجاد یک حلقه بی نهایت نشود.

پاس دادن توابع Generator

اکنون به جای اینکه عبارت break را به طور دستی بنویسیم یک تابع helper با نام take می نویسیم که ورودی اول آن یک Generator و ورودی دوم آن عدد محدودیت است.

function take($generator, $limit)
{
    foreach ($generator as $index => $value) {
        if ($index == $limit) break;
        yield $value;
    }
}

اکنون که تابع کمکی take را نوشتیم می توانیم یک Generator به آن پاس بدهیم تا از نتیجه ی آن در یک حلقه استفاده کنیم.

$allNumbersGenerator = generate_numbers();
$twentyNumbersGenerator = take($allNumbersGenerator, 20);

foreach ($twentyNumbersGenerator as $number) {
    dump($number);
}

بنابراین شما می توانید توابع Generator کمکی ای بنویسید که یک Generator را به عنوان ورودی دریافت می کنند و Generator جدیدی بر اساس مقادیر Generator ورودی بر می گردانند. اکنون شما این امکان را دارید که زنجیره ای از Generator ها بسازید و آنها را به یک دیگر پاس بدهید. در حقیقت این همان کاری است که لاراول در کلاس LazyCollection انجام می دهد.

اکنون که به دانش مناسبی از Generator ها در PHP رسیده ایم می توانیم به LazyCollection ها بر گردیم.

عملکرد Lazy Collection ها بر پایه یک تابع Generator

بر خلاف collection های عادی که یک آرایه PHP را در بر میگیرند، LazyCollection ها بر پایه یک تابع Generator ایجاد می شوند.

در تکه کد زیر تابع Generator ای که نوشته بودیم را به یک LazyCollection تبدیل می کنیم.

use Illuminate\Support\LazyCollection;
$collection = LazyCollection::make(function () {
    $number = 1;

    while (true) {
        yield $number++;
    }
});

در حال حاضر collection ای داریم که مجموعه ای از اعداد را نگه می دارد. اما توجه داشته باشید که این اعداد هنوز ساخته نشده اند. اکنون که یک collection داریم به این معنی است که تعداد زیادی از متد های پرکاربرد در اختیار ما هست. در حقیقت Collection ها و LazyCollection ها هر دو از اینترفیسی به نام Enumerable پیروی می کنند و به همین علت هر دوی آنها متد های ثابتی دارند.

با استفاده از متد take، به تعداد 10 عضو از LazyCollection ای که همه اعداد را نگه می دارد می گیریم.

$firstTenNumbers = $collection->take(10);

هر متدی که روی LazyCollection ها صدا می زنیم یک شی جدید از کلاس LazyCollection را بر می گرداند که درون آن یک تابع Generator جدید وجود دارد.

اکنون مثالی که ابتدای مقاله آورده بودیم را با اندکی تغییر باز نویسی می کنیم.

$collection = LazyCollection::times(INF)
    ->filter(fn ($number) => $number % 2 == 0)
    ->take(1000);

با collection ای که تعداد نا محدودی از اعداد را نگه می دارد آغاز کردیم. سپس از بین آنها اعداد زوج را filter کردیم و از میان آنها 1000 مقدار اول را گرفتیم.

همان طور که می دانید هنوز حتی یک مقدار هم تولید نشده و به همین خاطر است که این کد تقریبا هیچ حافظه ای از مموری اشغال نمی کند. این اعداد تنها وقتی ایجاد می شوند که ما شروع به پیمایش آنها بکنیم (با استفاده از foreach یا متد each در کلاس Collection).

LazyCollection::times(INF)
    ->filter(fn ($number) => $number % 2 == 0)
    ->take(1000)
    ->each(fn ($number) => dump($number));

اکنون در تکه کد بالا آن اعداد ساخته می شوند. به نظر شما چه تعداد عدد در تکه کد بالا ساخته می شود؟ قبل از اینکه ادامه این مطلب را بخوانید کمی به پاسخ این سوال فکر کنید.

در تکه کد بالا دو هزار عدد ساخته می شود. برای درک بهتر علت این موضوع به عملکرد متد filter دقت کنید. این متد مقادیری را از Generator اصلی بیرون می کشد که شرط filter را دارند. بنابراین برای اینکه 1000 عدد زوج بدست آورد باید 2000 عدد را بررسی کند و تنها نیمی از آنها را نگه دارد.

تبدیل Collection های عادی به LazyCollection

در این بخش می خواهیم بررسی کنیم که چگونه می توان یک collection عادی را به LazyCollection تبدیل کرد و انجام اینکار چه دلیلی می تواند داشته باشد.

تصور کنید در برنامه خود از یک API حجم زیادی داده دریافت کردید.

use Illuminate\Support\Collection;
function get_all_customers_from_api() : Collection
{
    // کدی که از api اطلاعات حجیم دریافت می کند
    // بازگرداندن اطلاعات به صورت collection
}

  پیاده سازی این تابع اهمیت چندانی ندارد. تمام چیزی که برای ما مهم می باشد این است که collection بزرگی از داده ها در مموری خود داریم. به عنوان مثال تعداد مشتریانی که در تهران موجودی بیش از 1000 دارند را بدست می آوریم.

$count = get_all_customers_from_api()
    ->where('city', 'tehran')
    ->where('balance', '>', '1000')
    ->count();

به نظرکار راحتی می آید اما نگاهی عمیق تر به این کد بیاندازید. هر دفعه که متد where را صدا می زنیم در واقع یک collection جدید در مموری خود می سازیم. با وجود اینکه همه مقادیر اصلی در مموری نگه داشته می شوند دلیلی ندارد مقادیر filter شده ما نیز در مموری ذخیره شوند. میتوانیم از متد ()lazy در collection های عادی استفاده کنیم تا آن را به یک LazyCollection تبدیل کنیم.

$count = get_all_customers_from_api()
    ->lazy()
    ->where('city', 'tehran')
    ->where('balance', '>', '1000')
    ->count();

اکنون در حالی که مقادیر اصلی در مموری هستند هیچ حافظه بیشتری برای filter کردن داده ها استفاده نمی شود.

جمع بندی

در این مطلب سعی شد نحوه کار LazyCollection ها در لاراول و Generator ها در PHP را توضیح دهیم. این قابلیت ها در بهینه سازی برنامه هایی که می نویسیم تاثیر به سزایی دارند. درصورتی که سوالی از این مطلب دارید در بخش نظرات با ما درمیان بگذارید.