
في الآونة الأخيرة ، أصبحت أنواع المراجع nullable موضوعًا ساخنًا. ومع ذلك ، فإن أنواع القيم الفارغة القديمة الجيدة لم تختف ولا تزال مستخدمة بنشاط. هل تتذكر جيدًا الفروق الدقيقة في العمل معهم؟ أقترح عليك تحديث أو اختبار معلوماتك من خلال قراءة هذا المقال. تم تضمين نموذج C # ورمز IL ، وإشارات إلى مواصفات CLI وكود CoreCLR. أقترح أن أبدأ بمشكلة مثيرة للاهتمام.
ملاحظة . إذا كنت مهتمًا بأنواع المراجع الفارغة ، فيمكنك الاطلاع على بعض مقالات زملائي: " أنواع مرجعية لاغية في C # 8.0 والتحليل الثابت " ، " المرجع Nullable غير محمي ، وإليك الدليل ."
ألق نظرة على رمز المثال أدناه وأجب عما سيتم إخراجه لوحدة التحكم. وبنفس الأهمية ، لماذا. فقط دعنا نتفق على الفور على أنك ستجيب كما هي: بدون تلميحات المترجم أو التوثيق أو قراءة الأدب أو شيء من هذا القبيل. :)
static void NullableTest()
{
int? a = null;
object aObj = a;
int? b = new int?();
object bObj = b;
Console.WriteLine(Object.ReferenceEquals(aObj, bObj)); // True or False?
}

حسنًا ، لنفكر قليلاً. لنأخذ بعض خطوط التفكير الرئيسية التي يبدو لي أنها يمكن أن تنشأ.
1. انطلاقا من حقيقة أن كثافة العمليات؟ - نوع مرجع.
دعونا نفكر بهذا الشكل ، ما هو int؟ هو نوع مرجعي. في هذه الحالة، و كتب قيمة ل اغية ، كما سيتم تسجيله و aObj بعد التعيين. سيتم كتابة إشارة إلى كائن ما في ب . ستتم كتابته أيضًا إلى bObj بعد التعيين. ونتيجة لذلك، Object.ReferenceEquals ستتخذ لاغية وغير باطل مرجع كائن كوسائط ، لذلك ...
ومن الواضح، فإن الجواب هو كاذبة!
2. ننطلق من حقيقة أن كثافة العمليات؟ - نوع مهم.
أو ربما تشك في ذلك ؟ - نوع مرجع؟ وهل أنت متأكد من هذا بالرغم من التعبير int؟ أ = فارغ ؟ حسنًا ، دعنا نذهب من الجانب الآخر ونبدأ من ما هو int؟ - نوع مهم.
في هذه الحالة ، يكون التعبير int؟ يبدو a = null غريبًا بعض الشيء ، لكن افترض أنه مرة أخرى في C # تم سكب السكر في الأعلى. اتضح أن a يخزن نوعًا من الأشياء. يخزن b أيضًا نوعًا من الأشياء. عند تهيئة المتغيرين aObj و bObj ، سيتم تعبئة العناصر المخزنة في a و b، ونتيجة لذلك ستتم كتابة مراجع مختلفة إلى aObj و bObj . اتضح أن Object.ReferenceEquals يأخذ الإشارات إلى كائنات مختلفة كوسيطات ، لذلك ...
كل شيء واضح ، والجواب خطأ!
3. نفترض أن Nullable <T> مستخدمة هنا .
لنفترض أنك لم تعجبك الخيارات أعلاه. لأنك تعلم جيدًا أنه لا يوجد int؟ في الواقع لا ، ولكن هناك نوع قيمة Nullable <T> ، وفي هذه الحالة سيتم استخدام Nullable <int> . كما أنك تفهم ذلك في الواقع في أ و بستكون هناك أشياء متطابقة. في الوقت نفسه ، لم تنسَ أنه عند كتابة القيم إلى aObj و bObj ، ستحدث التعبئة ، ونتيجة لذلك ، سيتم الحصول على إشارات إلى كائنات مختلفة. نظرًا لأن Object.ReferenceEquals يقبل الإشارات إلى كائنات مختلفة ، إذن ...
من الواضح أن الإجابة خطأ!
4 .؛)
بالنسبة لأولئك الذين بدأوا من أنواع القيم - إذا كان لديك فجأة أي شكوك حول مقارنة المراجع ، يمكنك إلقاء نظرة على الوثائق الموجودة على Object.ReferenceEquals على docs.microsoft.com... على وجه الخصوص ، يتطرق أيضًا إلى موضوع أنواع القيم والملاكمة / unboxing. صحيح ، إنه يصف حالة عندما يتم تمرير أمثلة من الأنواع المهمة مباشرة إلى الطريقة ، قمنا بإخراج العبوة بشكل منفصل ، لكن الجوهر هو نفسه.
عند مقارنة أنواع القيم. إذا كان objA و objB من أنواع القيم ، فسيتم وضعهما في محاصر قبل تمريرهما إلى طريقة ReferenceEquals. هذا يعني أنه إذا كان كل من objA و objB يمثلان نفس مثيل من نوع القيمة ، فإن طريقة ReferenceEquals تُرجع مع ذلك خطأ ، كما يوضح المثال التالي.
يبدو أن المقالة هنا يمكن أن تنتهي ، ولكن فقط ... الإجابة الصحيحة هي صواب .
حسنًا ، دعنا نفهم ذلك.
فهم
هناك طريقتان - بسيطة ومثيرة للاهتمام.
الطريق السهل
الباحث؟ غير صالح <int> . افتح وثائق Nullable <T> ، حيث ننظر إلى قسم "Boxing and Unboxing". من حيث المبدأ ، هذا كل شيء - يتم وصف السلوك هناك. ولكن إذا كنت تريد المزيد من التفاصيل ، فأنا أدعوك إلى مسار مثير للاهتمام. ؛)
طريقة مثيرة للاهتمام
لن يكون لدينا وثائق كافية على هذا المسار. تصف السلوك ولا تجيب على سؤال "لماذا"؟
ما هو int في الواقع ؟ و لاغية في السياق المناسب؟ لماذا يعمل مثل هذا؟ هل يستخدم كود IL أوامر مختلفة أم لا؟ هل يختلف السلوك على مستوى CLR؟ أي سحر آخر؟
لنبدأ بتحليل الكيان int؟ لتذكر الأساسيات ، والوصول تدريجيًا إلى تحليل الحالة الأصلية. نظرًا لأن C # هي لغة "فاتنة" إلى حد ما ، فسنشير بشكل دوري إلى رمز IL للنظر في جوهر الأشياء (نعم ، وثائق C # ليست طريقنا اليوم).
int؟ ، Nullable <T>
هنا سوف نلقي نظرة على أساسيات أنواع القيم الفارغة من حيث المبدأ (ما هي ، ما هي ترجمة في IL ، وما إلى ذلك). تمت مناقشة إجابة السؤال من المهمة في القسم التالي.
لنلقِ نظرة على جزء من الكود.
int? aVal = null;
int? bVal = new int?();
Nullable<int> cVal = null;
Nullable<int> dVal = new Nullable<int>();
على الرغم من أن تهيئة هذه المتغيرات تبدو مختلفة في C # ، إلا أنه سيتم إنشاء نفس كود IL لكل منهم.
.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
valuetype [System.Runtime]System.Nullable`1<int32> V_1,
valuetype [System.Runtime]System.Nullable`1<int32> V_2,
valuetype [System.Runtime]System.Nullable`1<int32> V_3)
// aVal
ldloca.s V_0
initobj valuetype [System.Runtime]System.Nullable`1<int32>
// bVal
ldloca.s V_1
initobj valuetype [System.Runtime]System.Nullable`1<int32>
// cVal
ldloca.s V_2
initobj valuetype [System.Runtime]System.Nullable`1<int32>
// dVal
ldloca.s V_3
initobj valuetype [System.Runtime]System.Nullable`1<int32>
كما ترون ، في C # كل شيء متبل بالسكر النحوي من القلب حتى نتمكن أنا وأنت من العيش بشكل أفضل ، في الواقع:
- الباحث؟ - نوع مهم.
- الباحث؟ - نفس Nullable <int>. يعمل كود IL مع Nullable <int32> .
- الباحث؟ aVal = null هو نفسه Nullable <int> aVal = new Nullable <int> () . في IL ، يتم توسيع هذا إلى جملة initobj تقوم بإجراء التهيئة الافتراضية على العنوان الذي تم تحميله.
ضع في اعتبارك الجزء التالي من الكود:
int? aVal = 62;
اكتشفنا التهيئة الافتراضية - رأينا رمز IL المقابل أعلاه. ماذا يحدث هنا عندما نريد تهيئة aVal إلى 62؟
دعنا نلقي نظرة على كود IL:
.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0)
ldloca.s V_1
ldc.i4.s 62
call instance void valuetype
[System.Runtime]System.Nullable`1<int32>::.ctor(!0)
مرة أخرى ، لا شيء معقد - يتم تحميل العنوان aVal على مكدس التقييم ، بالإضافة إلى القيمة 62 ، وبعد ذلك يتم استدعاء المُنشئ مع التوقيع Nullable <T> (T) . أي أن التعبيرين التاليين سيكونان متطابقين تمامًا:
int? aVal = 62;
Nullable<int> bVal = new Nullable<int>(62);
يمكنك رؤية الشيء نفسه من خلال النظر إلى رمز IL مرة أخرى:
// int? aVal;
// Nullable<int> bVal;
.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
valuetype [System.Runtime]System.Nullable`1<int32> V_1)
// aVal = 62
ldloca.s V_0
ldc.i4.s 62
call instance void valuetype
[System.Runtime]System.Nullable`1<int32>::.ctor(!0)
// bVal = new Nullable<int>(62)
ldloca.s V_1
ldc.i4.s 62
call instance void valuetype
[System.Runtime]System.Nullable`1<int32>::.ctor(!0)
ماذا عن عمليات التفتيش؟ على سبيل المثال ، كيف تبدو الشفرة التالية في الواقع؟
bool IsDefault(int? value) => value == null;
هذا صحيح ، للفهم ، دعنا ننتقل إلى كود IL المقابل مرة أخرى.
.method private hidebysig instance bool
IsDefault(valuetype [System.Runtime]System.Nullable`1<int32> 'value')
cil managed
{
.maxstack 8
ldarga.s 'value'
call instance bool valuetype
[System.Runtime]System.Nullable`1<int32>::get_HasValue()
ldc.i4.0
ceq
ret
}
كما قد تكون خمنت ، لا يوجد حقًا فارغ - كل ما يحدث هو استدعاء للخاصية Nullable <T> .HasValue . بمعنى ، يمكن كتابة نفس المنطق في C # بشكل أكثر وضوحًا من حيث الكيانات المستخدمة على النحو التالي.
bool IsDefaultVerbose(Nullable<int> value) => !value.HasValue;
كود IL:
.method private hidebysig instance bool
IsDefaultVerbose(valuetype [System.Runtime]System.Nullable`1<int32> 'value')
cil managed
{
.maxstack 8
ldarga.s 'value'
call instance bool valuetype
[System.Runtime]System.Nullable`1<int32>::get_HasValue()
ldc.i4.0
ceq
ret
}
دعونا نلخص:
- يتم تنفيذ أنواع القيم الفارغة على حساب النوع Nullable <T> ؛
- الباحث؟ - في الواقع النوع المركب لنوع القيمة العامة Nullable <T> ؛
- الباحث؟ a = null - تهيئة كائن من النوع Nullable <int> بالقيمة الافتراضية ، لا يوجد في الواقع أي قيمة خالية هنا ؛
- إذا (a == null) - مرة أخرى ، ليس هناك قيمة خالية ، هناك استدعاء للخاصية Nullable <T> .HasValue .
يمكن عرض الكود المصدري لنوع Nullable <T> ، على سبيل المثال ، على GitHub في مستودع dotnet / وقت التشغيل - وهو رابط مباشر إلى ملف التعليمات البرمجية المصدر . لا يوجد الكثير من التعليمات البرمجية ، لذا من أجل الاهتمام أنصحك بالبحث فيها. من هناك ، يمكنك تعلم (أو تذكر) الحقائق التالية.
للراحة ، يحدد نوع Nullable <T> :
- عامل التحويل الضمني من T إلى Nullable <T> ؛
- المشغل تحويل صريح من قيم الفارغة <T> إلى T .
يتم تنفيذ منطق العمل الرئيسي من خلال حقلين (والخصائص المقابلة):
- قيمة T - القيمة نفسها ، والتي يتم تغليفها بـ Nullable <T> ؛
- bool hasValue هي علامة تشير إلى ما إذا كان الغلاف يحتوي على قيمة. في علامات اقتباس، كما هو الحال في الواقع قيم الفارغة <T> دائما تحتوي على قيمة من نوع T .
الآن بعد أن أصبح لدينا تحديث لأنواع القيم الفارغة ، دعنا نرى ما حدث مع العبوة.
التعبئة لاغية <T>
دعني أذكرك أنه عند تعبئة كائن من نوع القيمة ، سيتم إنشاء كائن جديد على الكومة. يوضح مقتطف الشفرة التالي هذا السلوك:
int aVal = 62;
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
من المتوقع أن تكون نتيجة مقارنة المراجع خاطئة ، حيث حدثت عمليتا ملاكمة وتم إنشاء كائنين ، وكُتبت المراجع في obj1 و obj2 .
الآن قم بتغيير int إلى Nullable <int> .
Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
لا تزال النتيجة متوقعة - خطأ .
والآن ، بدلاً من 62 ، نكتب القيمة الافتراضية.
Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
ثالثا ... النتيجة صحيحة فجأة . يبدو أن لدينا جميع عمليات التعبئة نفسها ، حيث نقوم بإنشاء كائنين وروابط لكائنين مختلفين ، لكن النتيجة صحيحة !
نعم ، من المحتمل أنه سكر مرة أخرى ، وقد تغير شيء ما على مستوى كود IL! دعنا نرى.
مثال N1.
كود C #:
int aVal = 62;
object aObj = aVal;
كود IL:
.locals init (int32 V_0,
object V_1)
// aVal = 62
ldc.i4.s 62
stloc.0
// aVal
ldloc.0
box [System.Runtime]System.Int32
// aObj
stloc.1
مثال N2.
كود C #:
Nullable<int> aVal = 62;
object aObj = aVal;
كود IL:
.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
object V_1)
// aVal = new Nullablt<int>(62)
ldloca.s V_0
ldc.i4.s 62
call instance void
valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)
// aVal
ldloc.0
box valuetype [System.Runtime]System.Nullable`1<int32>
// aObj
stloc.1
مثال N3.
كود C #:
Nullable<int> aVal = new Nullable<int>();
object aObj = aVal;
كود IL:
.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
object V_1)
// aVal = new Nullable<int>()
ldloca.s V_0
initobj valuetype [System.Runtime]System.Nullable`1<int32>
// aVal
ldloc.0
box valuetype [System.Runtime]System.Nullable`1<int32>
// aObj
stloc.1
كما نرى ، تتم عملية التعبئة بنفس الطريقة في كل مكان - يتم تحميل قيم المتغيرات المحلية في مكدس التقييم (تعليمات ldloc ) ، وبعد ذلك يتم التعبئة نفسها عن طريق استدعاء الأمر box ، والذي من أجله يشار إلى النوع الذي سنقوم بالتعبئة.
ننتقل إلى مواصفات البنية الأساسية للغة العامة ، وننظر إلى وصف أمر المربع وابحث عن ملاحظة مثيرة للاهتمام فيما يتعلق بالأنواع
القابلة للإلغاء : إذا كان typeTok هو نوع قيمة ، فإن تعليمات الصندوق تحول val إلى شكلها المعبأ. ...إذا كان من النوع nullable ، فسيتم ذلك عن طريق فحص خاصية HasValue في val ؛ إذا كانت خاطئة ، يتم دفع مرجع فارغ إلى المكدس ؛ خلاف ذلك ، يتم دفع نتيجة خاصية قيمة صمام الملاكمة على المكدس.
من هنا توجد العديد من الاستنتاجات التي تشير إلى "أنا":
- تؤخذ حالة كائن <T> Nullable في الاعتبار (تم تحديد علامة HasValue التي اعتبرناها سابقًا ). إذا قيم الفارغة <T> لا تحتوي على قيمة ( HasValue هو كاذب )، و مربع سيؤدي لاغية .
- إذا كان Nullable <T> يحتوي على القيمة ( HasValue - true ) ، فلن يتم تعبئة الكائن Nullable <T> ، ولكن سيتم تخزين مثيل من النوع T ، والذي يتم تخزينه في حقل القيمة من نوع Nullable <T> ؛
- لا يتم تنفيذ المنطق المحدد للتعامل مع عبوات Nullable <T> على المستوى C # ، أو حتى على مستوى IL - يتم تنفيذه في CLR.
دعنا نعود إلى أمثلة Nullable <T> التي تمت مناقشتها أعلاه.
أول:
Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
حالة العنصر قبل التعبئة:
- T -> int ؛
- القيمة -> 62 ؛
- hasValue -> صحيح .
يتم تعبئة القيمة 62 مرتين (تذكر أنه في هذه الحالة ، يتم حزم مثيلات النوع int ، وليس Nullable <int> ) ، يتم إنشاء كائنين جديدين ، ويتم الحصول على مرجعين لكائنات مختلفة ، والنتيجة خاطئة .
ثانيا:
Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
حالة العنصر قبل التعبئة:
- T -> int ؛
- القيمة -> الافتراضي (في هذه الحالة ، 0 هي القيمة الافتراضية لـ int ) ؛
- hasValue -> خطأ .
منذ hasValue غير صحيحة ، يتم إنشاء أي أجسام على كومة، والملاكمة عوائد عملية لاغية ، والذي هو مكتوب على obj1 و obj2 المتغيرات . مقارنة هذه القيم ، كما هو متوقع ، يعطي صحيحًا .
في المثال الأصلي ، الذي كان في بداية المقال ، يحدث نفس الشيء تمامًا:
static void NullableTest()
{
int? a = null; // default value of Nullable<int>
object aObj = a; // null
int? b = new int?(); // default value of Nullable<int>
object bObj = b; // null
Console.WriteLine(Object.ReferenceEquals(aObj, bObj)); // null == null
}
من أجل المتعة ، دعنا نلقي نظرة على الكود المصدري CoreCLR من مستودع dotnet / وقت التشغيل المذكور سابقًا . نحن مهتمون بملف object.cpp ، على وجه التحديد - طريقة Nullable :: Box ، التي تحتوي على المنطق الذي نحتاجه:
OBJECTREF Nullable::Box(void* srcPtr, MethodTable* nullableMT)
{
CONTRACTL
{
THROWS;
GC_TRIGGERS;
MODE_COOPERATIVE;
}
CONTRACTL_END;
FAULT_NOT_FATAL(); // FIX_NOW: why do we need this?
Nullable* src = (Nullable*) srcPtr;
_ASSERTE(IsNullableType(nullableMT));
// We better have a concrete instantiation,
// or our field offset asserts are not useful
_ASSERTE(!nullableMT->ContainsGenericVariables());
if (!*src->HasValueAddr(nullableMT))
return NULL;
OBJECTREF obj = 0;
GCPROTECT_BEGININTERIOR (src);
MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
obj = argMT->Allocate();
CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);
GCPROTECT_END ();
return obj;
}
هنا كل ما تحدثنا عنه أعلاه. إذا لم نخزن القيمة ، نعيد NULL :
if (!*src->HasValueAddr(nullableMT))
return NULL;
وإلا فإننا ننتج التغليف:
OBJECTREF obj = 0;
GCPROTECT_BEGININTERIOR (src);
MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
obj = argMT->Allocate();
CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);
خاتمة
من أجل الاهتمام ، أقترح تقديم مثال من بداية المقال لزملائي وأصدقائي. هل سيتمكنون من إعطاء الإجابة الصحيحة وإثباتها؟ إذا لم يكن كذلك ، قم بدعوتهم لقراءة المقال. إذا استطاعوا - حسنًا ، احترامي!
أتمنى أن تكون مغامرة صغيرة ولكنها ممتعة. :)
ملاحظة : قد يكون لدى شخص ما سؤال: كيف بدأ الانغماس في هذا الموضوع؟ لقد وضعنا قاعدة تشخيصية جديدة في PVS-Studio حول حقيقة أن Object.ReferenceEquals يعمل مع الوسيطات ، ويتم تمثيل إحداها بنوع مهم. فجأة اتضح أنه مع Nullable <T> هناك لحظة غير متوقعة في سلوك التعبئة. ونحن ننظر في رمز IL - مربع كما مربع... ألق نظرة على مواصفات CLI - نعم ، هذا كل شيء! يبدو أن هذه حالة مثيرة للاهتمام إلى حد ما ، والتي تستحق القول - مرة واحدة! - والمقال أمامك.

إذا كنت ترغب في مشاركة هذه المقالة مع جمهور يتحدث الإنجليزية ، فيرجى استخدام رابط الترجمة: Sergey Vasiliev. تحقق من كيفية تذكر أنواع القيم الفارغة. دعونا نلقي نظرة خاطفة تحت الغطاء .
PPS بالمناسبة، في الآونة الأخيرة لقد كنت قليلا أكثر نشاطا على تويتر، حيث أقوم بنشر بعض مقتطفات الشفرة مثيرة للاهتمام، إعادة مشاركة tweet بعض أخبار مثيرة للاهتمام من العالم. NET، وشيء من هذا القبيل. أقترح البحث ، إذا كنت مهتمًا - اشترك ( رابط إلى الملف الشخصي ).