بررسی Data Locationها در زبان سالیدیتی

سلام دوستان

در این پست می‌خوام در مورد Data Locationها در زبان سالیدیتی یعنی storage، memory و calldata صحبت کنم. امید که مفید واقع بشه :blush:

این مقاله، براساس آخرین ورژن سالیدیتی یعنی نسخه ۰.۸.۷ نوشته شده است. برای نگارش این مقاله از داکیومنت سالیدیتی، سایت‌هایی مثل StackOverFlow، Youtube و … استفاده شده است.

مقدمه

به متغیرهایی که به صورت سراسری و در سطح قرارداد تعریف می‌شوند، State Variable می‌گوییم، چراکه با ارسال تراکنش در شبکه و با تغییر مقادیر این متغیرها، می‌توان حالت (State) قرارداد را تغییر داد.

به داده‌هایی که داخل توابع یا داخل struct تعریف می‌شوند، Local Variable می‌گوییم.

داده‌ها می‌توانند ساده (Value Type) یا پیچیده (Reference Type) باشند. داده‌های پیچیده داده‌هایی هستند که ممکن است در ۲۵۶ بیت جا نشوند. بنابراین، در ورودی و خروجی توابع باید حافظه این نوع داده‌ها مشخص شود.

داده‌های ساده مانند bool، داده‌های integer، address و …

داده‌های پیچیده مانند آرایه‌ها، mapping، structها و …

هر داده پیچیده، علاوه‌بر نوع، ویژگی دیگری بنام Data Location دارد که نشان می‌دهد آن داده در کجای حافظه ذخیره می‌شود. ۳ نوع Data Location داریم:

  • storage: داده‌های این بخش از حافظه به صورت دائمی در بلاکچین ذخیره می‌شوند. بنابراین، گس بیشتری مصرف می‌کنند.
  • memory: داده‌های این بخش از حافظه به صورت موقت ذخیره ‌می‌شوند و زمان اجرای آنها محدود به زمان اجرای تابع است.
  • calldata: مکانی از حافظه که برای ذخیره سازی پارامترهای توابع استفاده می‌شود. داده‌های این بخش از حافظه Read-Only هستند. calldata بسیار شبیه memory عمل می‌کند.

مروری بر Function Visibility ها

۴ نوع Visibility داریم:

  • private: فقط از داخل همین قرارداد به تابع دسترسی داریم.
  • internal: فقط از داخل همین قرارداد و قراردادهای فرزند به تابع دسترسی داریم.
  • external: فقط از خارج قرارداد به تابع دسترسی داریم.
  • public: هم از داخل و هم از خارج قرارداد به تابع دسترسی داریم.

در ورژن‌های قبلی سالیدیتی، حافظه پارامترهای ورودی و خروجی تمام توابع (به جز توابع external) memory بود. تنها استثنا در مورد توابع external بود که فقط پارامترهای ورودی این نوع توابع در calldata بودند. در ورژن‌های جدید سالیدیتی، حافظه پارامترها (ورودی و خروجی) کاملا قابل تعیین است.

مقایسه memory و storage

برای درک بهتر، این دو حافظه را می‌توان با هارد و رم کامپیوتر مقایسه کرد. درست همانند رم، حافظه memory در سالیدیتی مکانی موقتی برای ذخیره داده‌هاست؛ در حالیکه حافظه storage داده‌ها را به صورت دائمی بین اجراهای توابع نگهداری می‌کند. یک قرارداد هوشمند در حین اجرا میزان حافظه‌ای از memory در اختیار دارد که بعد از اتمام اجرا، این حافظه برای اجرای بعدی کاملا خالی می‌شود. درست مانند رم کامپوتر که بعد از اجرای یک برنامه کاملا رست می‌شود. در حالیکه، حافظه storage داده‌ها را به صورت دائمی نگهداری می‌کند. هر قرارداد به داده‌هایی که حین اجرای قبلی قرارداد، در حافظه storage ذخیره کرده است، دسترسی دارد.

اجرای هر تراکنش در شبکه اتریوم شامل هزینه‌ای موسوم به گس است. هر چقدر کدی که نوشته می‌شود، گس کمتری مصرف کند، کد بهینه‌تری است. گسی که حافظه memory مصرف می‌کند، در مقابل گسی که حافظه storage مصرف می‌کند، بسیار ناچیز است. بنابراین، بهتر است محاسبات میانی روی حافظه memory صورت گیرد و فقط نتایج محاسبات در storage نوشته شود.

نکاتی در رابطه با memory و storage

در ورژن‌های جدید سالیدیتی،‌ برای پارامترهای ورودی و خروجی توابع اگر از نوع داده‌های پیچیده باشند، باید Data Location را صراحتا مشخص کنیم.
وقتی قرار است پارامتر ورودی یک تابع، یک داده پیچیده از متغیرهای سراسری باشد باید مشخص کنیم که storage است.
اگر حداقل یکی از پارامترهای ورودی یک تابع از نوع storage تعریف شده باشد، Visibility آن تابع یا باید private باشد یا internal. دلیل آن کاملا مشخص است، چون فقط از داخل قرارداد به متغیرهای سراسری دسترسی داریم که بتوانیم به عنوان پارامتر به تابع ارسال کنیم.

اگر تابع کلا پارامتر ورودی نداشته باشد یا پارامتر ورودی از نوع storage نداشته باشد و پارامترها همگی از نوع memory و calldata باشند، محدودیتی از نظر Visibility نداریم؛ یعنی می‌توان از هر ۴ نوع (private، internal، external یا public) استفاده کرد.


متغیرهای سراسری (State Variables) همیشه در storage ذخیره می‌شوند و قابل تغییر نیستند. همچنین کلمه کلیدی storage هم نباید استفاده شود.

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

  • حافظه داده‌های ساده درون توابع، memory بوده و قابل تغییر نیست.
  • برای داده‌های پیچیده تعریف شده درون توابع باید صراحتا حافظه مشخص شود.

اختصاص دادن متغیر storage به متغیر storage فقط مقدار آن را کپی می‌کند (هم برای داده‌های پیچیده هم داده‌های ساده). در مثال زیر، بعد از اختصاص دادن مقادیر آرایه second به آرایه first، دوباره مقادیر آرایه second را تغییر دادیم، ولی همانطور که در خروجی مشاهده می‌شود، مقادیر آرایه first تغییر نکرده است و این یعنی فقط مقادیر کپی می‌شوند و ارجاع داده نمی‌شود.

اختصاص دادن متغیر memory به متغیر storage مانند حالت قبلی است و فقط مقادیر کپی می‌شوند.

اختصاص دادن متغیر storage به متغیر memory مانند حالت قبلی است و فقط مقادیر کپی می‌شوند.

اختصاص دادن متغیر memory به متغیر memory کمی متفاوت است. اگر متغیرها از نوع داده‌های ساده باشند (Value Type)، مانند حالت‌های قبلی فقط مقادیر کپی می‌شوند؛ اما اگر متغیرها از نوع داده‌های پیچیده باشند، ارجاع داده می‌شوند (تغییر در یک متغیر باعث تغییر مقادیر متغیر دیگر می‌شود).

نکاتی در رابطه با calldata

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

برخلاف ورژن‌های قبلی که استفاده از calldata محدود به توابع external بود، در ورژن‌های جدید، محدودیتی در استفاده از کلمه‌های کلیدی private، internal، external و public وجود ندارد. گرچه استفاده از calldata در توابع private و internal در حال حاضر کاربردهای محدودتری دارد.

این حافظه ارزان‌تر است، اما محدودیت‌هایی هم دارد؛ از جمله اینکه مقادیر این حافظه را نمی‌توان تغییر داد و در بدنه توابع نمی‌توان از calldata استفاده کرد.

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

در حالت external ماشین مجازی اتریوم می‌داند که قرار نیست این تابع از داخل صدا زده شود، پس اجازه دسترسی به اطلاعات از حافظه calldata را می‌دهد و نیازی به کپی اطلاعات به memory‌ نیست. به همین دلیل است که فقط می‌توان از این حافظه اطلاعات را خواند و امکان تغییر داده‌های calldata وجود ندارد.

در ورژن‌های قبلی سالیدیتی، calldata فقط با توابع external استفاده می‌شد و پارامترهای بقیه توابع در memory بودند. به طور کلی، فراخوانی‌های داخل قرارداد (یعنی توابع از نوع private و internal)، با Jump در کد صورت می‌گرفت و آرایه‌های موجود در پارامترهای این توابع، به صورت داخلی در کد و اشاره‌گر (Pointer) به مموری پاس داده می‌شدند ( چراکه پارامترها همیشه در memory بودند).

مشکل ورژن‌های قبلی سالیدیتی با توابع public بود. از آنجاییکه کامپایلر نمی‌دانست این تابع از داخل قرارداد هم صدا زده می‌شود یا نه، مجبور بود بدترین حالت را در نظر بگیرد و فرض کند که این تابع از داخل هم فراخوانی می‌شود. پس مجبور بود برای تولید کد و اشاره‌گر به مموری، پارامترها را از حافظه ارزان calldata به حافظه گران memory کپی کند (این مرحله در توابع external وجود نداشت) که هزینه اجرا را افزایش می‌داد.

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

طبق داکیومنت سالیدیتی، تا جاییکه امکان دارد، بهتر است از calldata استفاده شود.

نکته: حالتی که ورودی تابع calldata باشد و برای تغییر آن داخل تابع، یک کپی در memory بگیریم، نسبت به حالتی که ورودی تابع از ابتدا memory باشد، هزینه اجرای کمتری دارد (در شکل زیر هزینه اجرای تابع اول بسیار کمتر از هزینه اجرای تابع دوم است).

خلاصه

  • در ورژن‌های قبلی، پارامترهای توابع در memory ذخیره می‌شد (به جز توابع external که پارامترها در calldata ذخیره می‌شدند). در ورژن‌های جدید سالیدیتی، برای داده‌های پیچیده، در پارامتر ورودی و خروجی تابع باید نوع حافظه مشخص باشد.

  • عملکرد حافظه storage شبیه هارد کامپیوتر و عملکرد حافظه memory شبیه رم کامپیوتر است. حافظه storage داده‌ها را برای همیشه در بلاکچین ذخیره می‌کند. بنابراین، این حافظه گس بیشتری مصرف می‌کند. حافظه memory محدود به زمان اجرای قرارداد است و نسبت به storage ارزان‌تر است.

  • اختصاص متغیر storage به متغیر storage یا memory فقط مقدار را کپی می‌کند.

  • اختصاص متغیر memory به متغیر storage فقط مقدار را کپی می‌کند.

  • اختصاص متغیر ساده memory به memory فقط مقدار را کپی می‌کند. اما، اختصاص متغیر پیچیده memory به memory یک ارجاع به متغیر ایجاد می‌کند.

  • اختصاص داده calldata به storage یا memory فقط مقدار را کپی می‌کند.

  • calldata ارزان‌تر از memory است، اما محدودیت‌هایی دارد؛ از جمله اینکه فقط خواندنی‌اند، فقط در پارامتر توابع استفاده می‌شوند و یک تابع فقط می‌تواند پارامتر از calldata دریافت کرده، داده‌ها را خوانده و به همان صورت return کند.

  • داده‌های پیچیده‌ای که تابع return می‌کند، یا از حافظه memory برگردانده می‌شوند یا calldata.

  • اگر حداقل یکی از پارامترهای ورودی از نوع storage باشد، Visibility تابع یا باید private باشد یا internal.

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

20 پسندیده

ممنون از زحمتی که کشیدی
عالی بود:ok_hand::ok_hand::ok_hand:

1 پسندیده

خواهش می‌کنم. خوشحالم که مفید بوده.

1 پسندیده

ممنون از توضیحات خوبتون. فقط یک بخش را من شک دارم. فرموده بودید:
“اختصاص متغیر storage به متغیر storage فقط مقدار را کپی می‌کند”. من در سایت medium خواندم که زمانی که متغیر storage که state هست به متغیر storage که local هست نسبت داده می شود، کپی ایجاد نمی شود و یک reference ایجاد می شود.
مثال از سایت زیر گرفته شده است.
https://medium.com/coinmonks/solidity-fundamentals-a71bf54c0b98

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract DataLocations {
    address[] public addresses;
    mapping(uint => address) owners;
    
    struct Payment {
        uint amount;
        uint timestamp;
        address payable[] receiver;
    }
    mapping(uint => Payment) payments;
    
    function test() public view{
        // storage to storage
        //assign a reference
        Payment storage paymentOne = payments[1];
        //.....
    }
}
1 پسندیده

ممنون از شما.
مثالی که من زدم با مثال شما متفاوته، من تو مثالی که در عکس ششم زدم ۲ تا متغیر سراسری و برابر هم قرار دادم (بدون استفاده از کلمه کلیدی storage) که در این صورت همونطور که گفتم مقدار کپی میشه. اما بله اگر داخل تابع به صورت محلی یه متغیر ایجاد کنیم و صراحتا از کلمه کلیدی storage‌ استفاده کنیم و برابر یک متغیر سراسری قرار بدیم، یک ارجاع به state ایجاد می‌کنه. متغیر gradesInStorage در عکس پنجم دقیقا مثال شماست.
فکر می‌کنم بهتر بود این نکته رو تو متن توضیح می‌دادم که واضح‌تر باشه.

2 پسندیده

سلام
ممنون از اطلاعات ارایه شده
موفق باشید

1 پسندیده

ممنون بابت متن خوبت
عالی بود

1 پسندیده

عالی بود کاملا برام جا افتاد :heart_eyes:

مرسی هادی جان
مطلب بسیار عالی ای بود
درود بر شما و سپاس از به اشتراک گذاری این مطلب :hibiscus: