فصل ۴: Enumeration و Iteratorها، Nullable Typeها و Extension Methodها
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
الگوی Methodهای TryXXX
هنگام نوشتن یک Method، وقتی مشکلی رخ میدهد دو انتخاب دارید: نوعی Failure Code برگردانید یا Exception Throw کنید. بهطور کلی زمانی Exception Throw میکنید که خطا خارج از Workflow عادی باشد، یا انتظار داشته باشید فراخوانندهٔ مستقیم نتواند آن را مدیریت کند. بااینحال گاهی بهتر است هر دو انتخاب را در اختیار مصرفکننده قرار دهید. نمونهٔ آن Type با نام int است که دو نسخه از Method با نام Parse تعریف میکند:
public int Parse (string input);
public bool TryParse (string input, out int returnValue);
اگر Parsing ناموفق باشد، Parse Exception Throw میکند و TryParse مقدار false برمیگرداند.
میتوانید این الگو را با فراخوانی Method از نوع TryXXX در داخل Method از نوع XXX به شکل زیر پیادهسازی کنید:
public return-type XXX (input-type input)
{
return-type returnValue;
if (!TryXXX (input, out returnValue))
throw new YYYException (...)
return returnValue;
}
جایگزینهای Exception
همانند int.TryParse، یک Function میتواند با ارسال Error Code از طریق Return Type یا Parameter، Failure را به Function فراخواننده اطلاع دهد. این روش برای Failureهای ساده و قابل پیشبینی مناسب است، اما وقتی به خطاهای غیرمعمول یا غیرقابل پیشبینی گسترش یابد دستوپاگیر میشود، Method Signatureها را آلوده میکند و پیچیدگی و شلوغی غیرضروری ایجاد میکند.
این روش همچنین به Functionهایی که Method نیستند، مانند Operatorها ــ برای مثال Division Operator ــ یا Propertyها قابل تعمیم نیست. راه دیگر این است که خطا در محل مشترکی قرار گیرد که همهٔ Functionهای Call Stack آن را ببینند، مثلاً Static Methodی که خطای جاری را بهازای هر Thread نگه میدارد. اما این روش مستلزم آن است که هر Function در یک الگوی Error Propagation مشارکت کند که دستوپاگیر و بهطور طعنهآمیزی خود مستعد خطاست.
Enumeration و Iteratorها
Enumeration
Enumerator یک Cursor فقطخواندنی و فقطروبهجلو روی دنبالهای از مقادیر است. C# یک Type را Enumerator در نظر میگیرد اگر یکی از کارهای زیر را انجام دهد:
- Method عمومی و بدون Parameter به نام
MoveNext و Propertyای به نام Current داشته باشد. System.Collections.Generic.IEnumerator<T> را پیادهسازی کند.
System.Collections.IEnumerator را پیادهسازی کند.
Statement با نام foreach روی یک Object قابل Enumeration تکرار انجام میدهد. Object قابل Enumeration نمایش منطقی یک Sequence است. خود آن Cursor نیست، بلکه Objectی است که Cursorهایی روی خودش تولید میکند. C# یک Type را Enumerable در نظر میگیرد اگر یکی از موارد زیر را انجام دهد؛ بررسی نیز با همین ترتیب صورت میگیرد:
- Method عمومی و بدون Parameter به نام
GetEnumerator داشته باشد که Enumerator برگرداند. System.Collections.Generic.IEnumerable<T> را پیادهسازی کند.System.Collections.IEnumerable را پیادهسازی کند.- از C# 9، بتواند به Extension Methodی به نام
GetEnumerator که Enumerator برمیگرداند Bind شود؛ به بخش «Extension Methods» صفحهٔ 217 کتاب مراجعه کنید.
الگوی Enumeration به شکل زیر است:
class Enumerator // Typically implements IEnumerator or IEnumerator<T>
{
public IteratorVariableType Current { get {...} }
public bool MoveNext() {...}
}
class Enumerable // Typically implements IEnumerable or IEnumerable<T>
{
public Enumerator GetEnumerator() {...}
}
روش سطحبالا برای Iteration روی Characterهای کلمهٔ beer با Statement از نوع foreach:
foreach (char c in "beer")
Console.WriteLine (c);
روش سطحپایین برای Iteration روی Characterهای beer بدون استفاده از foreach:
using (var enumerator = "beer".GetEnumerator())
while (enumerator.MoveNext())
{
var element = enumerator.Current;
Console.WriteLine (element);
}
اگر Enumerator، IDisposable را پیادهسازی کند، Statement با نام foreach مانند یک Statement از نوع using نیز رفتار میکند و Object مربوط به Enumerator را بهصورت ضمنی Dispose میکند.
فصل ۷ Interfaceهای Enumeration را با جزئیات بیشتر توضیح میدهد.
Collection Initializerها و Collection Expressionها
با یک Collection Initializer میتوانید Object قابل Enumeration را در یک مرحله بسازید و پر کنید:
using System.Collections.Generic;
var list = new List<int> {1, 2, 3};
از C# 12 میتوانید این کد را با Collection Expression کوتاهتر کنید؛ به براکتهای مربعی توجه کنید:
using System.Collections.Generic;
List<int> list = [1, 2, 3];
Compiler این کد را به شکل زیر ترجمه میکند:
using System.Collections.Generic;
List<int> list = new List<int>();
list.Add (1);
list.Add (2);
list.Add (3);
این کار مستلزم آن است که Object قابل Enumeration، Interface با نام System.Collections.IEnumerable را پیادهسازی کند و Methodی به نام Add با تعداد مناسب Parameter برای Call داشته باشد. در Collection Expressionها، Compiler الگوهای دیگری را نیز برای ساخت Collectionهای فقطخواندنی پشتیبانی میکند.
به همین شکل میتوانید Dictionaryها را که در بخش «Dictionaries» صفحهٔ 394 بررسی میشوند مقداردهی کنید:
var dict = new Dictionary<int, string>()
{
{ 5, "five" },
{ 10, "ten" }
};
یا به شکل کوتاهتر:
var dict = new Dictionary<int, string>()
{
[3] = "three",
[10] = "ten"
};
شکل دوم فقط برای Dictionary معتبر نیست و برای هر Typeای که Indexer داشته باشد نیز معتبر است.
Iteratorها
درحالیکه Statement با نام foreach مصرفکنندهٔ Enumerator است، Iterator تولیدکنندهٔ Enumerator است. در مثال زیر با Iterator دنبالهای از اعداد Fibonacci را برمیگردانیم؛ هر عدد برابر مجموع دو عدد قبلی است:
using System;
using System.Collections.Generic;
foreach (int fib in Fibs(6))
Console.Write (fib + " ");
}
IEnumerable<int> Fibs (int fibCount)
{
for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++)
{
yield return prevFib;
int newFib = prevFib+curFib;
prevFib = curFib;
curFib = newFib;
}
}
OUTPUT: 1 1 2 3 5 8
درحالیکه Statement از نوع return میگوید «این همان مقداری است که از این Method خواستی برگردانم»، Statement از نوع yield return میگوید «این عنصر بعدی است که خواستی از این Enumerator Yield کنم». در هر Statement از نوع yield، Control به Caller برگردانده میشود، اما State مربوط به Callee حفظ میشود تا بهمحض اینکه Caller عنصر بعدی را Enumerate کرد اجرای Method ادامه یابد. طول عمر این State به Enumerator گره خورده است تا پس از اتمام Enumeration توسط Caller بتوان State را آزاد کرد.
Iteratorها میتوانند Local Method باشند؛ به بخش «Local methods» صفحهٔ 106 مراجعه کنید.
Semantics مربوط به Iterator
Iterator یک Method، Property یا Indexer است که یک یا چند Statement از نوع yield دارد. Iterator باید یکی از چهار Interface زیر را برگرداند؛ در غیر این صورت Compiler خطا ایجاد میکند:
// Enumerable interfaces
System.Collections.IEnumerable
System.Collections.Generic.IEnumerable<T>
// Enumerator interfaces
System.Collections.IEnumerator
System.Collections.Generic.IEnumerator<T>
Semantics مربوط به Iterator بسته به اینکه Enumerable Interface یا Enumerator Interface برگرداند متفاوت است. این موضوع را در فصل ۷ توضیح میدهیم.
وجود چند Statement از نوع yield مجاز است:
foreach (string s in Foo())
Console.WriteLine(s); // Prints "One","Two","Three"
IEnumerable<string> Foo()
{
yield return "One";
yield return "Two";
yield return "Three";
}
yield break
Statement از نوع return در Iterator Block غیرمجاز است؛ در عوض باید از yield break استفاده کنید تا مشخص کنید Iterator Block باید بدون برگرداندن عناصر بیشتر، زودتر خاتمه پیدا کند. میتوانیم Foo را به شکل زیر تغییر دهیم:
IEnumerable<string> Foo (bool breakEarly)
{
yield return "One";
yield return "Two";
if (breakEarly)
yield break;
yield return "Three";
}
Iteratorها و Blockهای try/catch/finally
Statement از نوع yield return نمیتواند در Block از نوع try که Clause از نوع catch دارد ظاهر شود:
IEnumerable<string> Foo()
{
try { yield return "One"; } // Illegal
catch { ... }
}
yield return همچنین نمیتواند داخل Block از نوع catch یا finally ظاهر شود. علت این محدودیتها این است که Compiler باید Iteratorها را به Classهای معمولی با اعضای MoveNext، Current و Dispose ترجمه کند و ترجمهٔ Blockهای Exception Handling پیچیدگی بیش از حد ایجاد میکند.
بااینحال میتوانید داخل Block از نوع try که فقط finally دارد Yield کنید:
IEnumerable<string> Foo()
{
try { yield return "One"; } // OK
finally { ... }
}
کد داخل finally زمانی اجرا میشود که Enumerator مصرفکننده به انتهای Sequence برسد یا Dispose شود. Statement با نام foreach اگر زودتر با break خارج شوید، Enumerator را بهصورت ضمنی Dispose میکند؛ بنابراین روش امنی برای مصرف Enumeratorهاست.
هنگام کار صریح با Enumeratorها یک دام این است که Enumeration را زودتر رها کنید بدون اینکه Enumerator را Dispose کنید؛ در نتیجه Block با نام finally دور زده میشود. میتوانید با قرار دادن استفادهٔ صریح از Enumerator داخل Statement از نوع using از این خطر جلوگیری کنید:
string firstElement = null;
var sequence = Foo();
using (var enumerator = sequence.GetEnumerator())
if (enumerator.MoveNext())
firstElement = enumerator.Current;
ترکیب Sequenceها
Iteratorها قابلیت Composition بسیار بالایی دارند. مثال را این بار طوری گسترش میدهیم که فقط اعداد Fibonacci زوج را خروجی دهد:
using System;
using System.Collections.Generic;
foreach (int fib in EvenNumbersOnly (Fibs(6)))
Console.WriteLine (fib);
IEnumerable<int> Fibs (int fibCount)
{
for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++)
{
yield return prevFib;
int newFib = prevFib+curFib;
prevFib = curFib;
curFib = newFib;
}
}
IEnumerable<int> EvenNumbersOnly (IEnumerable<int> sequence)
{
foreach (int x in sequence)
if ((x % 2) == 0)
yield return x;
}
هیچ عنصر تا آخرین لحظه محاسبه نمیشود؛ یعنی زمانی که یک عملیات MoveNext() آن را درخواست کند. شکل ۴-۱، درخواست داده و خروجی داده را در طول زمان نشان میدهد.
شکل ۴-۱ — جریان درخواست next و تحویل داده در ترکیب Fibonacci Enumerator، Even Enumerator و Consumer.
قابلیت Composition در الگوی Iterator در LINQ بسیار مفید است؛ این موضوع را دوباره در فصل ۸ بررسی میکنیم.
Nullable Value Typeها
Reference Typeها میتوانند با Reference از نوع null مقدار ناموجود را نمایش دهند. اما Value Typeها بهطور معمول نمیتوانند مقدار null داشته باشند:
string s = null; // OK, Reference Type
int i = null; // Compile Error, Value Type cannot be null
برای نمایش null در Value Type باید از ساختاری ویژه به نام Nullable Type استفاده کنید. Nullable Type با Value Type و سپس علامت ? نوشته میشود:
int? i = null; // OK, Nullable Type
Console.WriteLine (i == null); // True
Struct با نام Nullable<T>
T? به System.Nullable<T> ترجمه میشود که یک Structure سبک و Immutable است و برای نمایش Value و HasValue فقط دو Field دارد. ماهیت System.Nullable<T> بسیار ساده است:
public struct Nullable<T> where T : struct
{
public T Value {get;}
public bool HasValue {get;}
public T GetValueOrDefault();
public T GetValueOrDefault (T defaultValue);
...
}
کد:
int? i = null;
Console.WriteLine (i == null); // True
به کد زیر ترجمه میشود:
Nullable<int> i = new Nullable<int>();
Console.WriteLine (! i.HasValue); // True
اگر زمانی که HasValue برابر false است Property با نام Value را بخوانید، InvalidOperationException Throw میشود. GetValueOrDefault() اگر HasValue برابر true باشد Value را برمیگرداند؛ در غیر این صورت new T() یا Default Value سفارشی تعیینشده را برمیگرداند.
Default Value مربوط به T? برابر null است.
Implicit و Explicit Nullable Conversionها
Conversion از T به T? ضمنی است، اما Conversion از T? به T صریح است:
int? x = 5; // implicit
int y = (int)x; // explicit
Explicit Cast مستقیماً معادل فراخوانی Property با نام Value روی Nullable Object است؛ بنابراین اگر HasValue برابر false باشد InvalidOperationException Throw میشود.
Boxing و Unboxing مقادیر Nullable
وقتی T? Box میشود، مقدار Boxشده روی Heap شامل T است نه T?. این Optimization ممکن است چون یک Boxed Value یک Reference Type است و خودش میتواند null را نمایش دهد.
C# همچنین Unbox کردن Nullable Value Typeها با Operator با نام as را مجاز میداند. اگر Cast ناموفق باشد نتیجه null خواهد بود:
object o = "string";
int? x = o as int?;
Console.WriteLine (x.HasValue); // False
Operator Lifting
Struct با نام Nullable<T> Operatorهایی مانند <، > یا حتی == تعریف نمیکند. با وجود این، کد زیر بهدرستی Compile و اجرا میشود:
int? x = 5;
int? y = 10;
bool b = x < y; // true
دلیل این است که Compiler Operator «کمتر از» را از Value Type زیرین قرض میگیرد یا اصطلاحاً Lift میکند. از نظر Semantic، Expression مقایسهٔ قبلی را به این شکل ترجمه میکند:
bool b = (x.HasValue && y.HasValue) ? (x.Value < y.Value) : false;
یعنی اگر هم x و هم y مقدار داشته باشند، با Operator «کمتر از» مربوط به int مقایسه میشوند؛ در غیر این صورت false برمیگردد.
Operator Lifting یعنی میتوانید Operatorهای T را بهصورت ضمنی روی T? استفاده کنید. برای ایجاد رفتار ویژه در برابر null میتوانید Operator برای T? تعریف کنید، اما در اکثریت قریببهاتفاق موارد بهتر است به Compiler اجازه دهید منطق منظم Nullable را بهطور خودکار اعمال کند. چند مثال:
int? x = 5;
int? y = null;
// Equality operator examples
Console.WriteLine (x == y); // False
Console.WriteLine (x == null); // False
Console.WriteLine (x == 5); // True
Console.WriteLine (y == null); // True
Console.WriteLine (y == 5); // False
Console.WriteLine (y != 5); // True
// Relational operator examples
Console.WriteLine (x < 6); // True
Console.WriteLine (y < 6); // False
Console.WriteLine (y > 6); // False
// All other operator examples
Console.WriteLine (x + 5); // 10
Console.WriteLine (x + y); // null (prints empty line)
Compiler منطق null را بسته به دستهٔ Operator بهشکل متفاوتی اجرا میکند. بخشهای بعدی این قوانین متفاوت را توضیح میدهند.
Equality Operatorها: == و !=
Equality Operatorهای Liftشده، null را همانند Reference Typeها مدیریت میکنند؛ یعنی دو مقدار null برابرند:
Console.WriteLine ( null == null); // True
Console.WriteLine ((bool?)null == (bool?)null); // True
همچنین:
- اگر دقیقاً یکی از Operandها
null باشد، Operandها نابرابرند. - اگر هر دو Operand غیرNull باشند،
Valueهایشان مقایسه میشود.
Relational Operatorها: <، <=، >=، >
Relational Operatorها براساس این اصل کار میکنند که مقایسهٔ Operandهای null بیمعناست. بنابراین مقایسهٔ مقدار null با مقدار null یا غیرNull، false برمیگرداند:
bool b = x < y; // Translation:
bool b = (x.HasValue && y.HasValue)
? (x.Value < y.Value)
: false;
// b is false (assuming x is 5 and y is null)
همهٔ Operatorهای دیگر: +، −، *، /، %، &، |، ^، <<، >>، +، ++، --، !، ~
این Operatorها اگر هر یک از Operandها null باشد null برمیگردانند. این الگو برای کاربران SQL آشناست:
int? c = x + y; // Translation:
int? c = (x.HasValue && y.HasValue)
? (int?) (x.Value + y.Value)
: null;
// c is null (assuming x is 5 and y is null)
یک استثنا زمانی است که Operatorهای & و | روی bool? اعمال شوند که اندکی بعد بررسی میکنیم.
ترکیب Nullable و Non-nullable Typeها
میتوانید Nullable و Non-nullable Value Typeها را با هم ترکیب کنید؛ این قابلیت بهدلیل وجود Implicit Conversion از T به T? کار میکند:
int? a = null;
int b = 2;
int? c = a + b; // c is null - equivalent to a + (int?)b
bool? همراه Operatorهای & و |
وقتی Operandهایی از Type bool? داده شوند، Operatorهای & و | با null مانند مقدار ناشناخته رفتار میکنند. بنابراین null | true برابر true است، زیرا:
- اگر مقدار ناشناخته
false باشد، نتیجه true است. - اگر مقدار ناشناخته
true باشد، نتیجه باز هم true است.
به همین شکل null & false برابر false است. این رفتار برای کاربران SQL آشناست. مثال زیر سایر ترکیبها را نشان میدهد:
bool? n = null;
bool? f = false;
bool? t = true;
Console.WriteLine (n | n); // (null)
Console.WriteLine (n | f); // (null)
Console.WriteLine (n | t); // True
Console.WriteLine (n & n); // (null)
Console.WriteLine (n & f); // False
Console.WriteLine (n & t); // (null)
Nullable Value Typeها و Null Operatorها
Nullable Value Typeها با Operator با نام ?? بسیار خوب کار میکنند؛ به «Null-Coalescing Operator» صفحهٔ 83 مراجعه کنید:
int? x = null;
int y = x ?? 5; // y is 5
int? a = null, b = 1, c = 2;
Console.WriteLine (a ?? b ?? c); // 1 (first non-null value)
استفاده از ?? روی Nullable Value Type معادل فراخوانی GetValueOrDefault با Default Value صریح است، با این تفاوت که اگر Variable غیرNull باشد Expression مربوط به Default Value هرگز ارزیابی نمیشود.
Nullable Value Typeها همچنین با Null-Conditional Operator بهخوبی کار میکنند؛ به صفحهٔ 83 مراجعه کنید. در مثال زیر length به null ارزیابی میشود:
System.Text.StringBuilder sb = null;
int? length = sb?.ToString().Length;
میتوانیم این را با Null-Coalescing Operator ترکیب کنیم تا بهجای null مقدار صفر بهدست آید:
int length = sb?.ToString().Length ?? 0; // Evaluates to 0 if sb is null
سناریوهای Nullable Value Type
یکی از رایجترین سناریوهای Nullable Value Type نمایش مقادیر ناشناخته است. این موضوع در Database Programming زیاد رخ میدهد؛ جایی که یک Class به Tableای با Columnهای Nullable نگاشت میشود. اگر این Columnها String باشند، مانند Column با نام EmailAddress در Table با نام Customer، مشکلی نیست چون string در CLR یک Reference Type است و میتواند null باشد. اما بیشتر SQL Column Typeهای دیگر به Struct Typeهای CLR نگاشت میشوند؛ در نتیجه Nullable Value Typeها هنگام نگاشت SQL به CLR بسیار مفیدند:
// Maps to a Customer table in a database
public class Customer
{
...
public decimal? AccountBalance;
}
Nullable Type میتواند برای Backing Field چیزی که گاهی Ambient Property نامیده میشود نیز استفاده شود. Ambient Property اگر null باشد، مقدار Parent خود را برمیگرداند:
public class Row
{
...
Grid parent;
Color? color;
public Color Color
{
get { return color ?? parent.Color; }
set { color = value == parent.Color ? (Color?)null : value; }
}
}
جایگزینهای Nullable Value Type
پیش از اینکه Nullable Value Typeها بخشی از زبان C# شوند، یعنی قبل از C# 2.0، راهبردهای زیادی برای برخورد با این مسئله وجود داشت. نمونههای آن هنوز به دلایل تاریخی در Libraryهای .NET دیده میشوند. یکی از این راهبردها انتخاب یک مقدار غیرNull مشخص بهعنوان «مقدار null» است؛ نمونهای از آن در Classهای String و Array دیده میشود.
String.IndexOf وقتی Character پیدا نشود مقدار جادویی −1 را برمیگرداند:
int i = "Pink".IndexOf ('b');
Console.WriteLine (i); // −1
اما Array.IndexOf فقط وقتی −1 برمیگرداند که Index از صفر شروع شود. فرمول عمومیتر این است که IndexOf عددی یک واحد کمتر از Lower Bound آرایه برمیگرداند. در مثال بعد، وقتی عنصر پیدا نمیشود IndexOf مقدار صفر برمیگرداند:
// Create an array whose lower bound is 1 instead of 0:
Array a = Array.CreateInstance (typeof (string),
new int[] {2}, new int[] {1});
a.SetValue ("a", 1);
a.SetValue ("b", 2);
Console.WriteLine (Array.IndexOf (a, "c")); // 0
انتخاب یک «مقدار جادویی» به چند دلیل مشکلساز است:
- به این معناست که هر Value Type نمایش متفاوتی برای
null دارد. در مقابل، Nullable Value Typeها یک الگوی مشترک برای همهٔ Value Typeها فراهم میکنند. - ممکن است هیچ مقدار تعیینشدهٔ معقولی وجود نداشته باشد. در مثال قبلی همیشه نمیتوان از
−1 استفاده کرد. برای مثال قبلیِ نمایش Account Balance ناشناخته نیز همین موضوع صادق است. - فراموشکردن بررسی مقدار جادویی باعث تولید مقدار نادرستی میشود که ممکن است تا مراحل بعدی اجرا دیده نشود؛ زمانی که یک «ترفند جادویی» ناخواسته انجام میدهد. در مقابل، اگر روی Nullable Value با مقدار Null بررسی
HasValue را فراموش کنید، همان لحظه InvalidOperationException Throw میشود. - قابلیت Null بودن مقدار در Type منعکس نمیشود. Typeها Intent برنامه را منتقل میکنند، به Compiler اجازه میدهند صحت را بررسی کند و مجموعهای سازگار از قوانین را که Compiler اعمال میکند امکانپذیر میسازند.
Nullable Reference Typeها
درحالیکه Nullable Value Typeها قابلیت Nullability را به Value Typeها میآورند، Nullable Reference Typeها از C# 8 به بعد برعکس عمل میکنند. وقتی فعال شوند، درجهای از Non-nullability را برای Reference Typeها فراهم میکنند تا به جلوگیری از NullReferenceException کمک شود.
Nullable Reference Typeها سطحی از Safety را معرفی میکنند که صرفاً توسط Compiler و بهشکل Warning اعمال میشود؛ هرگاه Compiler کدی را تشخیص دهد که در معرض تولید NullReferenceException است هشدار میدهد.
برای فعالکردن Nullable Reference Typeها باید یا Element با نام Nullable را به فایل Project با پسوند .csproj اضافه کنید، اگر میخواهید برای کل Project فعال شود:
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
و/یا Directiveهای زیر را در قسمتهایی از کد که باید اثر کنند استفاده کنید:
#nullable enable // enables nullable reference types from this point on
#nullable disable // disables nullable reference types from this point on
#nullable restore // resets nullable reference types to project setting
پس از فعالشدن، Compiler Non-nullability را پیشفرض در نظر میگیرد. اگر میخواهید یک Reference Type بدون ایجاد Warning توسط Compiler مقدار null بپذیرد، باید Suffix با علامت ? را برای مشخصکردن Nullable Reference Type بهکار ببرید. در مثال بعد s1 Non-nullable و s2 Nullable است:
#nullable enable // Enable nullable reference types
string s1 = null; // Generates a compiler warning!
string? s2 = null; // OK: s2 is nullable reference type
کد زیر نیز Warning ایجاد میکند، زیرا x مقداردهی نشده است:
class Foo { string x; }
اگر x را با Field Initializer یا کد داخل Constructor مقداردهی کنید، Warning از بین میرود.
Null-Forgiving Operator
اگر Compiler فکر کند احتمال NullReferenceException وجود دارد، هنگام Dereference کردن Nullable Reference Type نیز هشدار میدهد. در مثال زیر دسترسی به Property با نام Length در String Warning تولید میکند:
void Foo (string? s) => Console.Write (s.Length);
با Null-Forgiving Operator یعنی ! میتوانید Warning را حذف کنید:
void Foo (string? s) => Console.Write (s!.Length);
استفاده از Null-Forgiving Operator در این مثال خطرناک است، چون ممکن است همان NullReferenceExceptionی را Throw کنیم که از ابتدا میخواستیم از آن جلوگیری کنیم. میتوانیم کد را به شکل زیر اصلاح کنیم:
void Foo (string? s)
{
if (s != null) Console.Write (s.Length);
}
اکنون دیگر به Null-Forgiving Operator نیاز نداریم. علت این است که Compiler Static Flow Analysis انجام میدهد و دستکم در موارد ساده آنقدر هوشمند است که تشخیص دهد Dereference چه زمانی امن است و احتمال NullReferenceException وجود ندارد.
توانایی Compiler در تشخیص و هشدار بینقص نیست و Coverage نیز محدودیتهایی دارد. برای مثال نمیتواند بداند عناصر یک Array مقداردهی شدهاند یا خیر؛ بنابراین کد زیر Warning تولید نمیکند:
var strings = new string[10];
Console.WriteLine (strings[0].Length);
جداکردن Annotation Context و Warning Context
فعالکردن Nullable Reference Typeها با Directive از نوع #nullable enable یا Setting پروژه با مقدار <Nullable>enable</Nullable> دو کار انجام میدهد:
- Nullable Annotation Context را فعال میکند؛ به Compiler میگوید همهٔ اعلانهای Variable از Reference Type را Non-nullable در نظر بگیرد مگر اینکه Suffix با علامت
? داشته باشند. - Nullable Warning Context را فعال میکند؛ به Compiler میگوید هنگام برخورد با کدی که در معرض Throw کردن
NullReferenceException است Warning تولید کند.
گاهی مفید است این دو مفهوم را جدا کنید و فقط Annotation Context یا، با کاربرد کمتر، فقط Warning Context را فعال کنید:
#nullable enable annotations // Enable the annotation context
// OR:
#nullable enable warnings // Enable the warning context
همین روش با #nullable disable و #nullable restore نیز کار میکند.
از طریق فایل Project نیز میتوانید این کار را انجام دهید:
<Nullable>annotations</Nullable>
<!-- OR -->
<Nullable>warnings</Nullable>
فعالکردن فقط Annotation Context برای Class یا Assembly خاص میتواند نخستین قدم مناسب برای واردکردن Nullable Reference Typeها به Codebase قدیمی باشد. با Annotation صحیح اعضای Public، اطمینان پیدا میکنید Class یا Assembly شما برای Classها و Assemblyهای دیگر «شهروند خوبی» است و آنها میتوانند از Nullable Reference Typeها کاملاً بهره ببرند، بدون اینکه مجبور باشید Warningهای داخل Class یا Assembly خود را در همان مرحله حل کنید.
تبدیل Nullable Warningها به Error
در Projectهای Greenfield منطقی است Nullable Context را از همان ابتدا کاملاً فعال کنید. ممکن است بخواهید قدم اضافهای بردارید و Nullable Warningها را به Error تبدیل کنید تا Project تا زمانی که همهٔ Null Warningها برطرف نشدهاند Compile نشود:
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
</PropertyGroup>
Extension Methodها
Extension Methodها اجازه میدهند یک Type موجود را بدون تغییر تعریف اصلی آن با Methodهای جدید گسترش دهید. Extension Method یک Static Method از یک Static Class است که Modifier با نام this روی Parameter اول آن اعمال میشود. Type مربوط به Parameter اول همان Typeای است که گسترش داده میشود:
public static class StringHelper
{
public static bool IsCapitalized (this string s)
{
if (string.IsNullOrEmpty(s)) return false;
return char.IsUpper (s[0]);
}
}
Extension Method با نام IsCapitalized را میتوان طوری فراخوانی کرد که گویی Instance Method روی یک String است:
Console.WriteLine ("Perth".IsCapitalized());
هنگام Compile، فراخوانی Extension Method دوباره به فراخوانی Static Method معمولی ترجمه میشود:
Console.WriteLine (StringHelper.IsCapitalized ("Perth"));
قاعدهٔ ترجمه به شکل زیر است:
arg0.Method (arg1, arg2, ...); // Extension method call
StaticClass.Method (arg0, arg1, arg2, ...); // Static method call
Interfaceها نیز قابل گسترشاند:
public static T First<T> (this IEnumerable<T> sequence)
{
foreach (T element in sequence)
return element;
throw new InvalidOperationException ("No elements!");
}
...
Console.WriteLine ("Seattle".First()); // S
زنجیرهکردن Extension Methodها
Extension Methodها همانند Instance Methodها راهی مرتب برای Chain کردن Functionها فراهم میکنند. دو Function زیر را در نظر بگیرید:
public static class StringHelper
{
public static string Pluralize (this string s) {...}
public static string Capitalize (this string s) {...}
}
x و y معادلاند و هر دو به "Sausages" ارزیابی میشوند؛ اما x از Extension Methodها و y از Static Methodها استفاده میکند:
string x = "sausage".Pluralize().Capitalize();
string y = StringHelper.Capitalize (StringHelper.Pluralize ("sausage"));
ابهام و Resolution
Namespaceها
Extension Method قابل دسترسی نیست مگر اینکه Class آن در Scope باشد؛ معمولاً با Import شدن Namespace مربوطه. Extension Method با نام IsCapitalized را در مثال زیر در نظر بگیرید:
using System;
namespace Utils
{
public static class StringHelper
{
public static bool IsCapitalized (this string s)
{
if (string.IsNullOrEmpty(s)) return false;
return char.IsUpper (s[0]);
}
}
}
برای استفاده از IsCapitalized، Application زیر باید Namespace با نام Utils را Import کند تا از Compile-time Error جلوگیری شود:
namespace MyApp
{
using Utils;
class Test
{
static void Main() => Console.WriteLine ("Perth".IsCapitalized());
}
}
Extension Methodها در برابر Instance Methodها
هر Instance Method سازگاری همیشه نسبت به Extension Method اولویت دارد. در مثال زیر، Method با نام Foo در Test همیشه اولویت خواهد داشت، حتی وقتی با Argumentی از Type int فراخوانی شود:
class Test
{
public void Foo (object x) { } // This method always wins
}
static class Extensions
{
public static void Foo (this Test t, int x) { }
}
تنها راه فراخوانی Extension Method در این حالت استفاده از Syntax معمول Static است؛ یعنی Extensions.Foo(...).