فصل ۳: Generics — Generic Typeها، Constraintها و Variance
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
Compiler خودش هنگام تولید Classهای private که state ساختارهایی مانند Iteratorها و Anonymous Methodها را capture میکنند، بهشدت از Nested Typeها استفاده میکند.
Generics
C# دو mechanism جداگانه برای نوشتن کدی دارد که در میان Typeهای مختلف reusable باشد: Inheritance و Generics. Inheritance قابلیت reuse را با یک Base Type بیان میکند، درحالیکه Generics قابلیت reuse را با «Template»ی بیان میکند که Typeهای «Placeholder» دارد. Generics در مقایسه با Inheritance میتوانند Type Safety را افزایش دهند و Casting و Boxing را کاهش دهند.
Generic Typeها
Generic Type، Type Parameterها را اعلان میکند؛ Typeهای Placeholder که توسط مصرفکنندهٔ Generic Type با Type Argumentها پر میشوند. در ادامه Generic Typeِ Stack<T> را میبینید که برای Stack کردن instanceهای Typeِ T طراحی شده است. Stack<T> یک Type Parameter واحد به نام T اعلان میکند:
public class Stack<T>
{
int position;
T[] data = new T[100];
public void Push (T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
میتوانیم از Stack<T> چنین استفاده کنیم:
var stack = new Stack<int>();
stack.Push (5);
stack.Push (10);
int x = stack.Pop(); // x is 10
int y = stack.Pop(); // y is 5
Stack<int> Type Parameterِ T را با Type Argumentِ int پر میکند و بهصورت ضمنی Typeای را در لحظه ایجاد میکند؛ synthesis در Runtime رخ میدهد. اما تلاش برای Push کردن string داخل Stack<int> Compile-time error تولید میکند. Stack<int> عملاً تعریف زیر را دارد؛ جایگزینیها در اصل با حروف برجسته نشان داده شدهاند و نام Class برای جلوگیری از سردرگمی با ### نمایش داده شده است:
public class ###
{
int position;
int[] data = new int[100];
public void Push (int obj) => data[position++] = obj;
public int Pop() => data[--position];
}
از نظر فنی میگوییم Stack<T> یک Open Type است، درحالیکه Stack<int> یک Closed Type است. در Runtime همهٔ Generic Type Instanceها closed هستند و Placeholder Typeها در آنها پر شدهاند. بنابراین statement زیر غیرمجاز است:
var stack = new Stack<T>(); // Illegal: What is T?
اما اگر این statement داخل Class یا Methodی باشد که خودش T را بهعنوان Type Parameter تعریف کرده است، مجاز خواهد بود:
public class Stack<T>
{
...
public Stack<T> Clone()
{
Stack<T> clone = new Stack<T>(); // Legal
...
}
}
چرا Generics وجود دارند
Generics برای نوشتن کدی وجود دارند که در میان Typeهای مختلف reusable باشد. فرض کنید به Stackی از Integerها نیاز داریم اما Generic Type نداریم. یک راهحل این است که برای هر Element Type موردنیاز نسخهای جداگانه از Class را hardcode کنیم؛ مانند IntStack، StringStack و غیره. روشن است که این کار Code Duplication قابلتوجهی ایجاد میکند. راه دیگر، نوشتن Stackی generalized با استفاده از object بهعنوان Element Type است:
public class ObjectStack
{
int position;
object[] data = new object[10];
public void Push (object obj) => data[position++] = obj;
public object Pop() => data[--position];
}
بااینحال ObjectStack برای Stack کردن Integerها بهاندازهٔ IntStack hardcodeشده خوب عمل نمیکند. ObjectStack به Boxing و Downcasting نیاز دارد که در Compile time قابل بررسی نیست:
// Suppose we just want to store integers here:
ObjectStack stack = new ObjectStack();
stack.Push ("s"); // Wrong type, but no error!
int i = (int)stack.Pop(); // Downcast - runtime error
چیزی که نیاز داریم هم یک implementation عمومی Stack است که برای همهٔ Element Typeها کار کند و هم راهی برای specialize کردن آسان آن Stack برای یک Element Type مشخص، تا Type Safety افزایش و Casting و Boxing کاهش یابد.
Generics دقیقاً همین قابلیت را با اجازهدادن به parameterize کردن Element Type فراهم میکنند. Stack<T> مزیتهای هر دو ObjectStack و IntStack را دارد. مانند ObjectStack، Stack<T> یکبار نوشته میشود تا بهطور عمومی روی همهٔ Typeها کار کند. مانند IntStack، Stack<T> برای یک Type خاص specialize میشود؛ زیبایی کار این است که این Type همان T است که در لحظه جایگزینش میکنیم.
ObjectStack از نظر functionality معادل Stack<object> است.
Generic Methodها
Generic Method داخل Signature یک Method، Type Parameter اعلان میکند. با Generic Methodها بسیاری از Algorithmهای بنیادی را میتوان بهشکل General-purpose پیادهسازی کرد. در ادامه Generic Methodی را میبینید که محتوای دو متغیر از هر Typeِ T را جابهجا میکند:
static void Swap<T> (ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
Swap<T> به شکل زیر فراخوانی میشود:
int x = 5;
int y = 10;
Swap (ref x, ref y);
معمولاً لازم نیست Type Argumentها را به Generic Method بدهید، زیرا Compiler میتواند Type را بهصورت implicit infer کند. اگر ambiguity وجود داشته باشد، Generic Methodها را میتوان با Type Argumentها اینگونه فراخوانی کرد:
Swap<int> (ref x, ref y);
داخل Generic Type، یک Method فقط زمانی Generic محسوب میشود که Type Parameter معرفی کند؛ با Syntax براکت زاویهای. Methodِ Pop در Generic Stack ما فقط از Type Parameter موجودِ Type یعنی T استفاده میکند و Generic Method محسوب نمیشود.
Methodها و Typeها تنها ساختارهایی هستند که میتوانند Type Parameter معرفی کنند. Property، Indexer، Event، Field، Constructor، Operator و غیره نمیتوانند Type Parameter اعلان کنند، هرچند میتوانند در Type Parameterهایی که قبلاً توسط Type دربرگیرنده اعلان شدهاند مشارکت کنند. برای نمونه، در Generic Stack میتوانیم Indexerی بنویسیم که Generic Item برگرداند:
public T this [int index] => data [index];
به همین ترتیب، Constructorها میتوانند از Type Parameterهای موجود استفاده کنند اما نمیتوانند Type Parameter جدید معرفی کنند:
public Stack<T>() { } // Illegal
اعلان Type Parameterها
Type Parameterها را میتوان در اعلان Class، Struct، Interface، Delegate ــ فصل ۴ ــ و Method معرفی کرد. ساختارهای دیگر مانند Property نمیتوانند Type Parameter معرفی کنند، اما میتوانند از آن استفاده کنند. برای مثال Propertyِ Value از T استفاده میکند:
public struct Nullable<T>
{
public T Value { get; }
}
Generic Type یا Method میتواند چند parameter داشته باشد:
class Dictionary<TKey, TValue> {...}
برای instantiate کردن:
Dictionary<int,string> myDict = new Dictionary<int,string>();
یا:
var myDict = new Dictionary<int,string>();
نام Generic Type و نام Method تا زمانی که تعداد Type Parameterها متفاوت باشد قابل overload هستند. برای مثال سه نام Type زیر با هم conflict ندارند:
class A {}
class A<T> {}
class A<T1,T2> {}
typeof و Unbound Generic Typeها
Open Generic Typeها در Runtime وجود ندارند؛ در فرایند Compilation بسته میشوند. بااینحال ممکن است Unbound Generic Type در Runtime وجود داشته باشد، صرفاً بهصورت یک Object از Typeِ Type. تنها راه مشخصکردن Unbound Generic Type در C# استفاده از Operatorِ typeof است:
class A<T> {}
class A<T1,T2> {}
...
Type a1 = typeof (A<>); // Unbound type (notice no type arguments).
Type a2 = typeof (A<,>); // Use commas to indicate multiple type args.
Open Generic Typeها همراه Reflection API استفاده میشوند؛ فصل ۱۸.
همچنین میتوانید با Operatorِ typeof یک Closed Type را مشخص کنید:
Type a3 = typeof (A<int,int>);
یا میتوانید Open Type مشخص کنید که در Runtime بسته میشود:
class B<T> { void X() { Type t = typeof (T); } }
Default Generic Value
میتوانید از کلمهٔ کلیدی default برای گرفتن default value یک Generic Type Parameter استفاده کنید. Default Value برای Reference Type برابر null است و برای Value Type حاصل bitwise-zero کردن Fieldهای Value Type است:
static void Zap<T> (T[] array)
{
for (int i = 0; i < array.Length; i++)
array[i] = default(T);
}
از C# 7.1، در مواردی که Compiler بتواند Type را infer کند میتوانید Type Argument را حذف کنید. خط آخر کد را میتوانیم با این جایگزین کنیم:
array[i] = default;
Generic Constraintها
بهطور پیشفرض میتوانید هر Type ممکنی را جایگزین Type Parameter کنید. Constraintها را میتوان روی Type Parameter اعمال کرد تا Type Argumentهای مشخصتری الزام شوند. Constraintهای ممکن عبارتاند از:
where T : base-class // Base-class constraint
where T : interface // Interface constraint
where T : class // Reference-type constraint
where T : class? // (See "Nullable Reference Types" in Chapter 4)
where T : struct // Value-type constraint (excludes Nullable types)
where T : unmanaged // Unmanaged constraint
where T : new() // Parameterless constructor constraint
where U : T // Naked type constraint
where T : notnull // Non-nullable value type, or (from C# 8)
// a non-nullable reference type
در مثال زیر، GenericClass<T,U> الزام میکند T از SomeClass derive شود ــ یا همان Type باشد ــ و Interface1 را implement کند؛ همچنین U باید Constructor بدون parameter داشته باشد:
class SomeClass {}
interface Interface1 {}
class GenericClass<T,U> where T : SomeClass, Interface1
where U : new()
{...}
Constraintها را هرجا Type Parameter تعریف میشود، چه در Method و چه در Type Definition، میتوانید اعمال کنید.
Base-Class Constraint مشخص میکند Type Parameter باید subclass یک Class مشخص باشد یا با آن مطابقت داشته باشد؛ Interface Constraint مشخص میکند Type Parameter باید آن Interface را implement کند. این Constraintها اجازه میدهند instanceهای Type Parameter بهصورت implicit به آن Class یا Interface convert شوند. فرض کنید میخواهیم Generic Methodی به نام Max بنویسیم که بزرگترینِ دو مقدار را برگرداند. میتوانیم از Generic Interface موجود در Namespaceِ System به نام IComparable<T> استفاده کنیم:
public interface IComparable<T> // Simplified version of interface
{
int CompareTo (T other);
}
CompareTo اگر this از other بزرگتر باشد عددی مثبت برمیگرداند. با استفاده از این Interface بهعنوان Constraint میتوانیم Methodِ Max را به شکل زیر بنویسیم؛ برای جلوگیری از distraction، Null Checking حذف شده است:
static T Max <T> (T a, T b) where T : IComparable<T>
{
return a.CompareTo (b) > 0 ? a : b;
}
Methodِ Max میتواند Argumentهای هر Typeی را که IComparable<T> را implement میکند بپذیرد؛ این شامل بیشتر Built-in Typeها مانند int و string است:
int z = Max (5, 10); // 10
string last = Max ("ant", "zoo"); // zoo
Constraintهای class و struct مشخص میکنند T باید Reference Type یا Value Type غیر-nullable باشد. نمونهٔ عالی Struct Constraint، Structِ System.Nullable<T> است که در «Nullable Value Types» صفحهٔ 210 بهتفصیل بررسی میکنیم:
struct Nullable<T> where T : struct {...}
Constraintِ unmanaged که در C# 7.3 معرفی شد نسخهای قویتر از Struct Constraint است: T باید Value Type ساده یا Structی باشد که بهصورت recursive هیچ Reference Typeای نداشته باشد.
Parameterless Constructor Constraint الزام میکند T یک Constructor عمومیِ بدون parameter داشته باشد. اگر این Constraint تعریف شده باشد، میتوانید روی T، new() فراخوانی کنید:
static void Initialize<T> (T[] array) where T : new()
{
for (int i = 0; i < array.Length; i++)
array[i] = new T();
}
Naked Type Constraint الزام میکند یک Type Parameter از Type Parameter دیگری derive شود ــ یا با آن match کند. در مثال زیر Methodِ FilteredStack، Stack دیگری را برمیگرداند که فقط subsetی از Elementها را دارد که Type Parameterِ U از Type Parameterِ T است:
class Stack<T>
{
Stack<U> FilteredStack<U>() where U : T {...}
}
Subclass کردن Generic Typeها
Generic Class را میتوان درست مانند Class غیرGeneric subclass کرد. Subclass میتواند Type Parameterهای Base Class را باز نگه دارد:
class Stack<T> {...}
class SpecialStack<T> : Stack<T> {...}
یا subclass میتواند Generic Type Parameterها را با Type concrete ببندد:
class IntStack : Stack<int> {...}
Subtype همچنین میتواند Type Argumentهای تازه معرفی کند:
class List<T> {...}
class KeyedList<T,TKey> : List<T> {...}
Self-Referencing Generic Declarationها
یک Type میتواند هنگام بستن Type Argument، خودش را بهعنوان Type concrete نام ببرد:
public interface IEquatable<T> { bool Equals (T obj); }
public class Balloon : IEquatable<Balloon>
{
public string Color { get; set; }
public int CC { get; set; }
public bool Equals (Balloon b)
{
if (b == null) return false;
return b.Color == Color && b.CC == CC;
}
}
موارد زیر نیز قانونیاند:
class Foo<T> where T : IComparable<T> { ... }
class Bar<T> where T : Bar<T> { ... }
Static Data
Static Data برای هر Closed Type یکتا است:
Console.WriteLine (++Bob<int>.Count); // 1
Console.WriteLine (++Bob<int>.Count); // 2
Console.WriteLine (++Bob<string>.Count); // 1
Console.WriteLine (++Bob<object>.Count); // 1
class Bob<T> { public static int Count; }
Type Parameterها و Conversionها
Cast Operator در C# میتواند چند نوع Conversion انجام دهد، از جمله:
- Numeric Conversion
- Reference Conversion
- Boxing/Unboxing Conversion
- Custom Conversion ــ از طریق Operator Overloading؛ فصل ۴.
تصمیم دربارهٔ اینکه کدام نوع Conversion انجام شود در Compile time و بر اساس Typeهای شناختهشدهٔ Operandها گرفته میشود. این موضوع با Generic Type Parameterها سناریوی جالبی ایجاد میکند، زیرا Type دقیق Operandها در Compile time ناشناخته است. اگر این وضعیت ambiguity ایجاد کند، Compiler error تولید میکند.
رایجترین سناریو زمانی است که میخواهید Reference Conversion انجام دهید:
StringBuilder Foo<T> (T arg)
{
if (arg is StringBuilder)
return (StringBuilder) arg; // Will not compile
...
}
بدون اطلاع از Type واقعی T، Compiler نگران است که شاید منظورتان Custom Conversion بوده باشد. سادهترین راهحل استفاده از Operatorِ as است که ambiguity ندارد، زیرا نمیتواند Custom Conversion انجام دهد:
StringBuilder Foo<T> (T arg)
{
StringBuilder sb = arg as StringBuilder;
if (sb != null) return sb;
...
}
راهحل عمومیتر این است که ابتدا به object cast کنید. این روش کار میکند چون Conversion به/از object Custom Conversion فرض نمیشود، بلکه Reference یا Boxing/Unboxing Conversion است. در این مورد StringBuilder یک Reference Type است، پس باید Reference Conversion باشد:
return (StringBuilder) (object) arg;
Unboxing Conversionها نیز میتوانند ambiguity ایجاد کنند. کد زیر ممکن است Unboxing، Numeric یا Custom Conversion باشد:
int Foo<T> (T x) => (int) x; // Compile-time error
راهحل دوباره این است که ابتدا به object و سپس به int cast کنید؛ در این حالت بهطور غیرمبهم Unboxing Conversion را signal میکند:
int Foo<T> (T x) => (int) (object) x;
Covariance
با فرض اینکه A قابل convert به B باشد، اگر X<A> قابل convert به X<B> باشد میگوییم X دارای Covariant Type Parameter است.
برای نمونه، Typeِ IFoo<T> اگر کد زیر قانونی باشد دارای T از نوع covariant است:
IFoo<string> s = ...;
IFoo<object> b = s;
Interfaceها اجازهٔ Covariant Type Parameter میدهند ــ Delegateها نیز؛ فصل ۴ ــ اما Classها چنین اجازهای ندارند. Arrayها نیز Covariance را پشتیبانی میکنند: اگر A به B Implicit Reference Conversion داشته باشد، A[] را میتوان به B[] convert کرد؛ این موضوع برای مقایسه در همین بخش بررسی میشود.
Variance خودکار نیست
برای تضمین Static Type Safety، Type Parameterها بهصورت خودکار variant نیستند. مثال زیر را در نظر بگیرید:
class Animal {}
class Bear : Animal {}
class Camel : Animal {}
public class Stack<T> // A simple Stack implementation
{
int position;
T[] data = new T[100];
public void Push (T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
کد زیر Compile نمیشود:
Stack<Bear> bears = new Stack<Bear>();
Stack<Animal> animals = bears; // Compile-time error
این محدودیت احتمال Runtime Failure در کد زیر را از بین میبرد:
animals.Push (new Camel()); // Trying to add Camel to bears
بااینحال نبود Covariance میتواند Reusability را محدود کند. برای مثال فرض کنید میخواهیم Methodی بنویسیم که Stackی از Animalها را Wash کند:
public class ZooCleaner
{
public static void Wash (Stack<Animal> animals) {...}
}
فراخوانی Wash با Stackی از Bearها Compile-time error ایجاد میکند. یک workaround، بازتعریف Methodِ Wash با Constraint است:
class ZooCleaner
{
public static void Wash<T> (Stack<T> animals) where T : Animal { ... }
}
اکنون میتوانیم Wash را چنین فراخوانی کنیم:
Stack<Bear> bears = new Stack<Bear>();
ZooCleaner.Wash (bears);
راهحل دیگر این است که Stack<T> Interfaceای با Covariant Type Parameter implement کند؛ کمی بعد خواهید دید.
Arrayها
به دلایل تاریخی، Array Typeها Covariance را پشتیبانی میکنند. یعنی اگر B subclassِ A باشد و هر دو Reference Type باشند، B[] را میتوان به A[] cast کرد:
Bear[] bears = new Bear[3];
Animal[] animals = bears; // OK
هزینهٔ این Reusability آن است که Element Assignmentها ممکن است در Runtime شکست بخورند:
animals[0] = new Camel(); // Runtime error
اعلان Covariant Type Parameter
Type Parameterهای Interface و Delegate را میتوان با modifierِ out بهصورت covariant اعلان کرد. این modifier تضمین میکند برخلاف Arrayها، Covariant Type Parameterها کاملاً Type-safe باشند.
میتوانیم این موضوع را با Classِ Stack<T> خود نشان دهیم؛ آن را وادار میکنیم Interface زیر را implement کند:
public interface IPoppable<out T> { T Pop(); }
Modifierِ out روی T نشان میدهد T فقط در Output Positionها ــ مثلاً Return Type Methodها ــ استفاده میشود. out Type Parameter را covariant علامتگذاری میکند و اجازه میدهد چنین کاری انجام دهیم:
var bears = new Stack<Bear>();
bears.Push (new Bear());
// Bears implements IPoppable<Bear>. We can convert to IPoppable<Animal>:
IPoppable<Animal> animals = bears; // Legal
Animal a = animals.Pop();
Conversion از bears به animals بهواسطهٔ covariant بودن Type Parameter توسط Compiler مجاز است. این کار Type-safe است، زیرا وضعیتی که Compiler میخواهد از آن جلوگیری کند ــ Push کردن Camel روی Stack ــ نمیتواند رخ دهد؛ هیچ راهی برای واردکردن Camel به Interfaceای که T فقط در Output Positionها در آن ظاهر میشود وجود ندارد.
میتوانیم از قابلیت cast کردن بهصورت covariant برای حل مشکل Reusability قبلی استفاده کنیم:
public class ZooCleaner
{
public static void Wash (IPoppable<Animal> animals) { ... }
}
اگر Covariant Type Parameter را در Input Position ــ مانند Parameter یک Method یا Writable Property ــ استفاده کنید، Compiler error تولید میکند.
Contravariance
پیشتر دیدیم با فرض اینکه A به B Implicit Reference Conversion داشته باشد، Typeِ X اگر X<A> به X<B> Reference Conversion داشته باشد Covariant Type Parameter دارد. Contravariance زمانی است که بتوانید در جهت معکوس convert کنید؛ از X<B> به X<A>. این قابلیت وقتی پشتیبانی میشود که Type Parameter فقط در Input Positionها ظاهر شود و با modifierِ in مشخص شده باشد. مثال قبلی را گسترش دهید و فرض کنید Classِ Stack<T> Interface زیر را implement میکند:
public interface IPushable<in T> { void Push (T obj); }
اکنون کد زیر قانونی است:
IPushable<Animal> animals = new Stack<Animal>();
IPushable<Bear> bears = animals; // Legal
bears.Push (new Bear());
هیچ Memberی در IPushable یک T را output نمیکند، بنابراین با cast کردن animals به bears نمیتوانیم به مشکل بخوریم؛ مثلاً از طریق این Interface هیچ راهی برای Pop کردن وجود ندارد.
بهعنوان مثال دیگر، Interface زیر را که در Namespaceِ System تعریف شده در نظر بگیرید:
public interface IComparer<in T>
{
// Returns a value indicating the relative ordering of a and b
int Compare (T a, T b);
}
چون Interface دارای T contravariant است، میتوانیم از IComparer<object> برای مقایسهٔ دو String استفاده کنیم:
var objectComparer = Comparer<object>.Default;
// objectComparer implements IComparer<object>
IComparer<string> stringComparer = objectComparer;
int result = stringComparer.Compare ("Brett", "Jemaine");
بهصورت mirrorِ Covariance، اگر تلاش کنید Contravariant Type Parameter را در Output Position ــ مثلاً Return Value یا Readable Property ــ استفاده کنید، Compiler error گزارش میکند.
Generics در C# در برابر Templateها در C++
Generics در C# از نظر کاربرد شبیه Templateها در C++ هستند، اما بسیار متفاوت کار میکنند. در هر دو حالت باید synthesis میان Producer و Consumer رخ دهد که در آن Placeholder Typeهای Producer توسط Consumer پر میشوند. بااینحال در Genericsِ C#، Producer Typeها ــ یعنی Open Typeهایی مانند List<T> ــ میتوانند داخل Library مانند mscorlib.dll Compile شوند. این امکان به این دلیل است که synthesis میان Producer و Consumer که Closed Typeها را تولید میکند در واقع تا Runtime رخ نمیدهد. در Templateهای C++ این synthesis در Compile time انجام میشود. یعنی در C++، Template Libraryها را بهشکل DLL deploy نمیکنید؛ آنها فقط بهصورت Source Code وجود دارند. این تفاوت همچنین inspect کردن dynamic و حتی بیشتر از آن ساخت Parameterized Typeها در لحظه را دشوار میکند.
برای بررسی عمیقتر دلیل این موضوع، دوباره Methodِ Max در C# را در نظر بگیرید:
static T Max <T> (T a, T b) where T : IComparable<T>
=> a.CompareTo (b) > 0 ? a : b;
چرا نتوانستیم آن را به شکل زیر پیادهسازی کنیم؟
static T Max <T> (T a, T b)
=> (a > b ? a : b); // Compile error
دلیل این است که Max باید یکبار Compile شود و برای همهٔ مقدارهای ممکن T کار کند. Compilation نمیتواند موفق شود زیرا برای Operatorِ > یک معنای واحد در همهٔ مقدارهای T وجود ندارد؛ در واقع هر T حتی Operatorِ > هم ندارد. در مقابل، کد زیر همان Methodِ Max را با Templateهای C++ نشان میدهد. این کد برای هر مقدار T جداگانه Compile میشود، Semantic مربوط به > برای همان T را میگیرد و اگر T خاصی Operatorِ > را پشتیبانی نکند Compile نمیشود:
template <class T> T Max (T a, T b)
{
return a > b ? a : b;
}
[این صفحه در فایل اصلی بدون متن است.]