مهدی
مهدی
خواندن ۱۲ دقیقه·۱ سال پیش

بررسی عمیق آبجکت‌ها و تایپ‌ها در C layer مفسر CPython

بسیار شنیدیم که همه‌چیز در پایتون آبجکت است. با اینکه این جمله درسته ولی خب یه سوال پیش میاد! زبان C که پایتون نیست، پایتون هم که C نیست پس چجوری با C چیزی نوشتن که توی پایتون ما به همه‌چیز میگیم آبجکت! حتی اگر source code پایتون رو هم دیده باشید با چنین خطوطی مواجه می‌شید:

همونطور که شاید حدس زده باشید این‌ها توابعی برای تایپ datetime هستند. return type‌های همه‌شون یه پوینتر از تایپ PyObject هست، تایپ بعضی آرگومان‌هاشون همین‌طور!!

این‌ها هم C API برای تایپ لیست در پایتون!

و این هم جزئی از مهم‌ترین فایل و قدیمی‌ترین فایل‌های source code عه CPython، که به قول خودشون این یک ارث‌بری دست‌ساز برای خودمونه D:



توی این مقاله بررسی می‌کنیم:

  • ساختاری به نام PyObject
  • ساختاری برای typeها
  • تایپ tuple


ساختاری به نام PyObject

همونطور که بالاتر هم نشون دادم، یه نگاه گذرا به سورس کد CPython بهتون نشون میده که چه بسیار از struct عه PyObject استفاده میشه، در واقع وقتی مفسر داره کد ما رو توی interpreter loop (حلقه‌ی خیلی بزرگی که دونه دونه بایت‌کد‌‌های مارو اجرا میکنه) اجرا میکنه، همه‌ی مقادیر رو توی evaluation stack به عنوان PyObject در نظر میگیره. اگه بخوایم خیلی بهتر بهش اشاره کنیم میتونیم بگیم این PyObject سوپرکلاس تمامی آبجکت‌های پایتونه (همونطوری که توی پایتون میگیم کلاس object سوپرکلاس همه‌ی کلاس‌های ماست و همه ازش ارث می‌برن.)‌
البته مقادیر در پایتون هیچ‌وقت به عنوان PyObject تعریف (declare) نمیشن بلکه همیشه یک pointer به مقادیر *میتونه* به PyObject اصطلاحا cast بشه.

سخنی در مورد C structs
وقتی میگیم: مقادیر در پایتون هیچ‌وقت به عنوان PyObject تعریف (declare) نمیشن بلکه همیشه یک pointer به مقادیر *میتونه* به PyObject اصطلاحا cast بشه. داریم به یکی از implemention detailهای زبان C و اینکه این زبان مکان دیتا در مموری رو برای خودش تفسیر می‌کنه، اشاره می‌کنیم.
برای تعریف تمامی آبجکت‌های پایتون از C structها استفاده شده. C structها چیزی جز *گروهی از بایت‌ها* در مموری نیستن! که به ما اجازه میدن هرطوری که ما میخوایم بهشون اشاره کنیم، این یعنی چی؟ یعنی فرض کنید یه struct عه test داریم که ۵ مقدار short داخل خودش داره:
که هر مقدار short دو بایت هست و در کل این struct ده بایت از مموری رو اشغال میکنه. حال در C یه رفرنس به ده بایت در مموری رو میتونیم ما به test کَست کنیم و *فرض کنیم* که این ده بایت رو یک struct عه test ساخته (که البته که میتونه نساخته باشه.) این به این معنی هست که وقتی ما n بایت دیتا که داره یک آبجکت پایتونی رو نشون میده داشته باشیم و این n از سایز PyObject بزرگ‌تر باشه، میتونیم اون n بایت ابتداییش رو به عنوان PyObject در نظر بگیریم و به اون cast کنیم.

این هم PyObject:

اون ماکرو عه PyObject_HEAD_EXTRA_ اینه:

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


که دو تا فیلد ob_next_ و ob_prev_ رو تعریف میکنه که به آبجکت ساخته شده‌ی قبلی و بعدی اشاره میکنن که شبیه به یک doubly linked list میشه و یعنی همممه‌ی آبجکت‌های پایتون مثل زنجیر بهم وصل میشن D:

فیلدِ ob_refcnt که یه جور عدده برای memory management اسفاده میشه و فیلد مهم ob_type هم یک پوینتر به یک ساختار دیگه‌ست که type اون آبجکت رو مشخص میکنه. این پوینتر مشخص میکنه که:

  • این دیتا چیه! (what the data represents)
  • چه جور دیتایی داخلش هست و
  • چه کار‌هایی (رفتار‌‌های آبجکت) میشه روی این آبجکت‌ انجام داد.

که توی پایتون میشه اینجوری بهش نگاه کرد:

این اسم name داره به یک آبجکت string اشاره میکنه که تایپ اون آبجکت str هست.

سوال یک میلیون دلاری ?

? این چطور امکان‌پذیره؟!!

سخنی در مورد reference counting
مفسر CPython از reference counting برای memory management استفاده میکنه؛ این یک روش ساده‌ست که وقتی یک *اسم* جدید به یک آبجکت bound میشه (مثل مثال بالا برای name و اون 'obj') reference count عه اون آبجکت زیاد میشه و برعکس، وقتی یک رفرنس از یک آبجکت کم میشه (برای مثال استفاده از del) reference count اون هم کم میشه. و وقتی reference count یک آبجکت صفر بشه، اون آبجکت توسط VM اصطلاحا deallocate میشه.

خیلی خوب، بالاتر صحبت از این کردیم که همه‌ی مقادیر رو میشه به PyObject کست کرد، و صحبت از اون implementation detail هم کردیم که در C structها وجود داره، اما میشه اون رو، توی source code هم ببینیم؟

چه چیزی توی همه‌شون مشترکه؟ بلی دقیقا این ماکرو:

این ماکرو یک ob_base از نوع PyObject برای اون‌ها میگذاره که همین باعث میشه که بشه (طبق همون توضیحات بالا راجع به C structها،) اون‌ها رو به PyObject کست کرد.




تایپ PyVarObject خواهر PyObject

اول یه سر سورس کد آبجکت لیست رو ببینیم:

خبری از PyObject_HEAD نیست و یک ماکرو دیگه جاش هست: PyObject_VAR_HEAD.

این ماکرو:

که یک ob_base از PyVarObject برای آبجکت میگذاره، اما خب این PyVarObject که به عنوان خواهر PyObject میشناسیمش چیه؟
اینه:

این struct برای آبجکت‌هایی مثل list استفاده میشه که به قول انگلیسیا:

they have some *notion* of length

و خب تعداد آیتم‌های درونی اون‌ آبجکت رو نگهداری می‌کنن؛ و چنین میشه تابع len در پایتون همیشه از (1)O برخوردار باشه ? چون صرفا باید یک مقدار رو بخونه و برگردونه.



ساختاری برای typeها

تایپ‌ها در CPython توسط struct عه typeobject_ تعریف می‌شوند. این C struct در واقع base تمامی تایپ‌های مورد استفاده در CPython هست و فیلد‌های زیادی که داره که اکثراً پوینتر‌هایی به C functionهای دیگه هستن که کارایی (functionality) عه اون تایپ رو پیاده‌سازی (implement) می‌کنند.

که اینه:

تمامی این فیلد‌ها و مورد استفاده‌شون به طور کامل در اینجا:

https://docs.python.org/3.11/c-api/typeobj.html

راجع بهشون صحبت شده.

اما بیاید تا با یک مثال این ساختار رو بررسی کنیم.

تایپ tuple:

اول از همه آبجکت تاپل:

این هم تایپ تاپل:

  • ماکروی ابتدای این struct:

یادتونه که گفتیم همه‌ی آبجکتا رو میتونیم به PyObject کست کنیم؟ و یادتونه که خواهر PyObject که PyVarObject رو معرفی کردیم که خود این PyVarObject هم میشد به PyObject کست کرد؟ و باز هم یادتونه که توی PyObject چه فیلد‌هایی رو داشتیم؟

یک فیلد که ob_refcnt بود و یک فیلد‌ هم ob_type، و ما دقیقا داریم با این ماکرو اون ob_type این struct رو مقدار میدیم؟ اونم چه مقداری! جواب سوال میلیون دلاری بالا: بله، تایپ عه تایپ D:

این باعث میشه که خروجی زیر رو ما ببینیم:

تایپ عه type همون متاکلاس پیشفرض تمامی کلاس‌های پایتونه که اگه به سورس کدش نگاه کنیم:

می‌بینیم که تایپ عه تایپ، تایپ هست :) و این هم اثبات این ماجرا:

حال اینکه چرا از PyVarObject_HEAD_INIT و آوردن فیلد ob_size استفاده شده رو جلوتر راجع بهش توضیح میدم.

  • فیلد tp_name:

این فیلد برای نام‌گذاری این تایپ استفاده میشه که توی مثال ما tuple هست. در واقع __name__ این فیلد رو میخونه و به ما برمی‌گردونه:

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

  • فیلد‌های tp_basicsize و tp_itemsize:

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

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

برای tp_itemsize دو حالت پیش میاد، طبق حرف documentation برای آبجکت‌هایی که variable-size هستن‌ (مثل مثال ما) این فیلد باید مقداری که اونا نگه میدارن رو ذخیره کنه (که همه پوینتر‌هایی از PyObject هستند و سایزشون توی سیستم‌های ۳۲ بیت ۴ بایت و ۶۴ بیت ۸ بایت هست) همچین بازم طبق حرف داکیومنتیشن، سایز اون‌ها به این صورت محاسبه میشه:

یادتونه بالاتر گفتیم که چرایی استفاده از ماکروی PyVarObject_HEAD_INIT رو توضیح میدم و میگم چرا اون فیلد ob_size باید باشه؟ خب همینجا داک بهمون گفته، یبار دیگه با دقت بخونیدش (خطوط قرمز):

میگه برای آبجکت‌های variable-length این فیلد ob_size باید باشه، چرا؟ چون ما برای محاسبه‌ی سایز اون آبجکت به N که *length* عه اون آبجکت هست نیاز داریم که اون مقدار length در ob_size ذخیره شده. اینجا رو نگاه کنید:

که همونطور که مشخصه، ۸ تا ۸ تا اومده روی سایز تاپل.

حالت دومی هم که برای tp_itemsize اتفاق میوفته، اینه که اون آبجکت variable-length نباشه و اصطلاحا statically allocated type objects باشه و در اون صورت این فیلد باید برای اون آبجکت 0 در نظر گرفته بشه، مثل همین *تایپ* خودمون:

  • تابع tupledealloc برای فیلد tp_dealloc:

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

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

  1. این تابع برای تایپ‌هایی که هیچوقت از بین نمیرن، یعنی None و Ellipsis، تعریف نمیشه!

۲. برای نکته‌ی دوم اول این تیکه کد از tupledealloc رو ببینید:

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

می‌بینید، وقتی که s رو توی یک تاپل گذاشتیم، یکی به رفرنس‌هاش اضافه شد و وقتی تنها اسمی که به اون تاپل اشاره می‌کرد رو حذف کردیم، اون رفرنس هم از s کم شد.
حال داکیومنتیشن چی میگه:

۳. اما داکیومنتیشن حرف‌های دیگه هم برای *چطور نوشتن* این تابع داره:

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

  • تابع tuplerepr برای فیلد tp_repr:

این تابع طبق حرف داکیومنتیشن:

اما خطوطی که زیرشون خط کشیده شده، یکی از best practiceهایی که هست که در مورد نوشتن __repr__ هست: این تابع باید مقدار str عی رو برگردونه که در شرایط مناسب، اگر اون به تابع eval پاس بدیم اون instance رو برامون بسازه؛ اما چیزی که کمتر شنیده شده اینه که اگر مقدور نبود باید مقدار str عی رو برگردونه که اولش < و آخرش > باشه که تایپ و ولیو‌ عه آبجکت رو به ما بگه.

  • مقادیر tuple_as_sequence:

این فیلد که Sequence Protocol رو برای تاپل مشخص میکنه، این sub slot‌ها رو داره:

این یعنی تاپل‌ها:

  • مقدار len دارن و میتونیم به تابع len اون‌ها رو پاس بدیم
  • میتونیم اون‌ها رو با + کانکت کنیم
  • میتونیم از repetition استفاده کنیم
  • می‌تونیم آیتم‌هاشون با ایندکس بگیریم
  • و از عمل‌گر in روشون استفاده کنیم
  • مقادیر tuple_as_mapping:

اگه تعجب کردید که عه! از اسلایس‌ها که میتونیم روی تاپل‌ها استفاده کنیم پس کوش؟! باید بگم که تابعی که امکان subscript کردن تاپل‌ها رو فراهم میکنه توی این پروتوکل آورده شده:

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

  • تابع tuplehash:

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

اگه بریم تایپ لیست رو ببینیم:

که به این می‌رسیم:

ارور اشنایی هست، نه؟ ?

  • تابع PyObject_GenericGetAttr:

طبق حرف داکیومنتیشن:

اما به طور خلاصه، این تابع روال عادی گرفتن یک attribute از یک آبجکت رو implement می‌کنه.

  • فیلد tp_doc با مقدار:

این فیلد مقدار __doc__ رو ست میکنه و در واقع همون چیزیه که تابع help هم ازش استفاده میکنه:

  • فیلد tp_richcompare و تابع tuplerichcompare:

تابعی که در این فیلد قرار میگیره، ساپورت عمل‌های مقایسه‌ای رو برای اون تایپ میاره:

  • فیلد tp_iter و تابع tuple_iter:

فرض کنید می‌خوایم یک کلاسی بنویسیم که بتونیم روش حلقه‌ی for بزنیم، باید چی کار کنیم؟ می‌رسیم به تعریف Iterableها:

آبجکت‌های iterable به زبان ساده، آبجکت‌هایی هستند که وقتی به تابع iter اون‌ها رو پاس بدیم یک آبجکت iterator به ما بدن! در داکیومنتیشن پایتون باز هم اشاره شده که Containerها اگه میخوان iterable باشن باید متد __iter__ رو implement کنند و خب در C layer، اون تایپ باید تابع مناسبی رو به slot عه tp_iter بده:

پس تا اینجا متوجه شدیم (و البته می‌دونستیم) که تاپل یک container عه iterable عه!

حال، آبجکت iterator چیه؟

به آبجکتی که این دو متد رو پیاده‌سازی کنه، میگن یک Iterator.

حالا بیاید یه مرور کنیم: اگه میخوایم (برای مثال) روی یک آبجکت حلقه‌ی for بزنیم، اون آبجکت باید __iter__ رو داشته باشه، __iter__ چی برمیگردونه؟ یک Iterator. وظیفه‌ی iterator چیه؟ اینه که هر باری که روش next رو صدا می‌زنیم (حلقه‌ی for خودش این کار رو میکنه) یک آیتم بهمون برگردونه و وقتی دیگه آیتمی نبود، StopIteration رو raise کنه. حالا بریم ببینیم این اتفاق برای تاپل‌ها چجوری اتفاق میوفته.

اول از همه __iter__:

که این تابع هست:

دقیقا طبق گفته‌های داک، صرفا داره یک iterator رو بر میگردونه.

حالا بریم ببینیم اون iterator که از تایپ PyTupleIterObject_ هست، چیه:

یه PyObject_HEAD که راجع بهش حرف زدیم، یک index و یک PyTupleObject همین D:

اما ماهایی که داریم این مقاله رو می‌خونیم دقت داریم که این یک آبجکت هست و خب چیزی که *باید* داشته باشه چیه؟ بلی باید یک تایپ داشته باشه (که اون ob_type داخل PyObject ست بشه دیگه)

حالا تایپ این IterObject چیه:

که چیزی که برای مهمه، دقیقا همین تابع tupleiter_next هست. بریم این تابع رو هم ببینیم:

که خیییلی ساده‌ست. اگه دقت کنید یک IterObject میگیره و از فیلد index اش استفاده میکنه و آیتم بعدی رو با PyTuple_GET_ITEM میگیره و ریترن‌ش میکنه.

فیلد tp_methods:

این فیلد متد‌هایی که اون تایپ داره رو در خودش ذخیره میکنه، برای مثال برای تاپل‌ها ما متد index رو داریم، اون اینجا تعریف میشه:

که این ماکرو عه:

که در نهایت می‌رسیم به این تابع (تابع tuple_index این تابع رو صدا میزنه):

می‌بینید یه حلقه‌ی for که دونه دونه آیتم‌ها رو بررسی میکنه و اگه پیداش کرد index رو برمیگردونه.

  • فیلد tp_new:

و در نهایت تابعی که یک آبجکت جدید تاپل می‌سازه:

این تابع مسئول ساختن آبجکت جدید تاپل هست و در واقع __new__ عه اون تایپ محسوب میشه.


ارتباط بین تایپ‌ها و آبجکت‌ها

صرف تعریف و نوشتن یک C struct که میگه این آبجکت قراره چه فیلد‌هایی داشته باشه، باعث ساخته شدن و تعریف شدن یک آبجکت یا تایپ جدید در پایتون نمیشه، یکی از فیلد‌های ضروری عه PyObject فیلدِ ob_type هست که در واقع این struct عه مهم قراره اون آبجکت رو تعریف کنه، ساختن نابود شدنش رو مشخص کنه، بگه چه پروتوکل‌هایی رو پشتیبانی می‌کنه و... پس میشه اینطور نتیجه گرفت که: از بهم وصل کردن یک آبجکت (که PyObject_HEAD داره) و تعریف فیلد ob_type اون از نوع struct عه PyTypeObject، یک آبجکت یا تایپ جدید در پایتون داریم که میتونیم ازش استفاده کنیم.

??? که یک چنین چیزی میشه:

اگر می‌خواهید تایپ خودتون رو بنویسید و توی Python ازش استفاده کنید این tutorial می‌تونه به شما کمک کنه:

https://docs.python.org/3/extending/newtypes_tutorial.html



امیدوارم لذت برده باشید.

پایتونpythonبرنامه نویسیprogrammingbest practice
سلام، من مهدی‌ام، مطالعه‌ی تخصصیم پایتونه و هر از چندی یه مقاله راجع به پایتون می‌نویسم
شاید از این پست‌ها خوشتان بیاید