مروری بر اسمبلیهای .NET
کدی که برای میزبانی توسط .NET Runtime نوشته شده باشد، کد مدیریتشده (managed
code) نامیده میشود و کدی که توسط .NET Runtime میزبانی نشود، کد مدیریتنشده
(unmanaged code) نامیده میشود. از نظر تئوری، ما میتوانیم از زبان C# به همان
شیوهای که برنامههای C و C++ نوشته میشوند، یعنی بدون هدف قرار دادن Runtime
استفاده کنیم اما در این صورت، بخش زیادی از قابلیتهای C# غیر قابل استفاده
خواهند بود. بنابراین، منطقی است که از زبان C# تنها برای تولید کدهای
مدیریتشده استفاده کنیم که توسط .NET Runtime میزبانی میشوند.
واحد باینری که شامل کد مدیریتشده است، اسمبلی (assembly) نامیده میشود. اسمبلیهای
.NET مثل باینریهای مدیریتنشده دارای پسوند .dll هستند اما بهکلی
با آنها متفاوت هستند. بر خلاف باینریهای مدیریتنشده که شامل دستورالعملهای مختص پلتفرم
هستند، اسمبلیها شامل کدهایی به یک زبان میانی به نام CIL یا Common Intermediate Language
به همراه متادیتا هستند.
پس، وقتی یک کامپایلر .NET کد نوشتهشده به یک زبان .NET را کامپایل
میکند، واحد باینری ایجاد شده یک اسمبلی نامیده میشود. در مورد اسمبلیها، فعلاً دانستن
چهار ویژگی زیر کفایت میکند:
- بر خلاف اسمبلیهای فریمورک کلاسیک .NET که میتوانستند به فرمت
.dll یا .exe باشند، اسمبلیهای .NET فقط دارای فرمت
.dll هستند؛ حتی اگر از نوع اجرایی (executable) باشند. اسمبلیهای اجرایی
.NET با استفاده از دستور dotnet <assembly
name>.dll اجرا میشوند. توجه داشته باشید که از .NET Core
3.0
به بعد، کامند dotnet.exe در دایرکتوری build کپی شده و به
<assembly name>.exe تغییر نام میدهد. اجرای
این
کامند باعث فراخوانی dotnet <assembly name>.dll میشود
و
با کامند dotnet <assembly name>.dll معادل است. از
.NET 6 به بعد، اپلیکیشن ما میتواند در قالب یک فایل خلاصه شود. با وحود
تشابه این رویکرد با آنچه در مورد باینریهای مدیریتنشده (مانند اجراییهای
C++ ) رخ میدهد، باید بدانید که این تکفایل بودن صرفاً بهخاطر سادگی پکیج
کردن برنامههاست و این تکفایل هر آنچه را که برنامه برای اجرا نیاز دارد، شامل است و
حتی میتواند شامل خود Runtime باشد. به هر حال، این تکفایل همچنان در یک کانتینر
مدیریتشده و زیر نظر .NET Runtime اجرا میشود.
- یک اسمبلی .NET شامل کد CIL است که از نظر مفهومی مشابه bytecode در جاواست
و تا زمانی که واقعاً نیاز نباشد، به کد ماشین تبدیل نمیشود. این زمان نیاز، معمولاً
وقتی است که بخشی از کد CIL (مثل پیادهسازی یک متد) توسط Runtime مورد ارجاع قرار
میگیرد.
- یک اسمبلی .NET علاوه بر کدهای CIL شامل متادیتای نوع (type metadata) نیز
هست که توصیفی از نوعهای استفاده شده در برنامه را ارائه میکند. به عنوان مثال، اگر
یک کلاس با نام Car در برنامه خود داشته باشید. متادیتا جزئیات مربوط به کلاس Car و
کلاس پایهی (base class) این کلاس، اینترفیسهایی که احتمالاً این کلاس پیادهسازی
کرده و جزئیات مربوط به اعضای آن را ارائه میدهد. متادیتا در هر اسمبلی
.NET وجود دارد و تولید آن بر عهدهی کامپایلر است.
- و بالاخره اینکه یک اسمبلی علاوه بر کدهای CIL و متادیتای نوع، شامل متادیتای خاصی با
نام مانیفست (manifest) است که اطلاعاتی را در مورد ورژن اسمبلی جاری و لیست اسمبلیهای
اکسترنالی که این اسمبلی به آنها ارجاع دارد، ارائه میدهد. وجود مانیفست در اسمبلی
باعث میشود که اسمبلیها کاملاً خود توصیف باشند و برنامهها یا اسمبلیهای دیگر که
کدی را در اسمبلی مذکور فراخوانی میکنند، نیازی به مراجعه به رجیستری یا هر منبع دیگر
به منظور اطلاع از چگونگی استفاده از آن اسمبلی نداشته باشند.
بنابراین، یک اسمبلی .NET شامل کد CIL، متادیتای نوع و مانیفست است.در
ادامه، هر یک از این مؤلفهها را با جزئیات بیشتر بررسی میکنیم.
نقش CIL در یک اسمبلی
همانطور که گفتیم، CIL مخفف Common Intermediate Language است. ایدهی کامپایل کدها به یک
زبان میانی که برای Runtime قابل درک باشد، قبل از .NET در جاوا هم وجود داشته و
چیز جدیدی نیست. اما باز هم تفاوت در عبارت Common در ابتدای نام زبان میانی
.NET است که به مشترک بودن آن بین همهی زبانهای .NET اشاره دارد.
کد C# زیر یک ماشین حساب ساده را مدل میکند. الان نیازی نیست نگران گرامر این
کدها باشید اما به فرمت متد Add() در کلاس Calc توجه کنید.
Calc c = new Calc();
int ans = c.Add(10, 84);
Console.WriteLine("10 + 84 is {0}.", ans);
Console.ReadLine();
class Calc
{
public int Add(int addend1, int addend2)
{
return addend1 + addend2;
}
}
پس از کامپایل این کدها، یک اسمبلی .dll تولید خواهد شد که شامل کدهای CIL،
مانیفست و متادیتای نوع است که کلاس Calc را توصیف
میکند.
در فصل دوم خواهیم دید که چطور میتوانیم با استفاده از یک IDE مانند Visual
Studio فایلهای کد خود را کامپایل کنیم.
اگر این اسمبلی را با برنامهای مثل ildasm.exe باز کنید، خواهید دید که متد
Add() با استفاده از کدهای CIL به صورت زیر مشخص شده است:
.method public hidebysig instance int32 Add(int32
addend1,
int32 addend2) cil managed
{
// Method begins at RVA 0x2090
// Code
size 9 (0x9)
.maxstack 2
.locals /*11000002*/ init (int32 V_0)
IL_0000: /* 00 |
*/ nop
IL_0001: /* 03 | */ ldarg.1
IL_0002: /* 04 | */ ldarg.2
IL_0003: /* 58 |
*/ add
IL_0004: /* 0A | */ stloc.0
IL_0005: /* 2B | 00 */ br.s IL_0007
IL_0007:
/* 06 | */ ldloc.0
IL_0008: /* 2A | */ ret
} // end of method Calc::Add
بعد از اینکه در فصل هجدهم با زبان CIL و گرامر آن بیشتر آشنا شدید، میتوانید کدهای بالا را
کاملاً درک کنید. برای الان، مطلبی که باید بدانید این است که کامپایلر C# کدهای
منبع را به کدهای CIL تبدیل میکند و نه به کدهای ماشین. این مطلب در مورد همهی
کامپایلرهای .NET صادق است. اگر همین برنامه را به جای C# با استفاده
از VB یا F# بنویسید، خواهید دید که کد CIL تولید شده با کد بالا یکسان است.
کامپایل CIL به کدهای ماشین
کامپایل کدهای منبع به CIL مزایای مهمی را به همراه دارد اما به هرحال، طبیعتاً برای
اجرای برنامه باید این کدهای CIL تولید شده در نهایت به کدهای ماشین یعنی کدهای بامعنا
و قابل درک برای CPU تبدیل شوند. این کار نه در زمان کامپایل، بلکه در زمان اجرای
برنامه و توسط کامپایلر JIT یا Just In Time انجام میشود. این کامپایلر که به صورت غیر
رسمی jitter نیز نامیده میشود، در زمان اجرای برنامه، کدهای CIL موجود در اسمبلی را با
توجه به ویژگیهای سختافزاری و نرمافزاری ماشین هدف به بهترین شکل برای آن ماشین
کامپایل میکند. .NET Runtime به هر CPU یک jitter جداگانه اختصاص میدهد که
برای پلتفرم و سیستمعامل هدف بهینه شده است. به عنوان مثال، کامپایلری گه برای کامپایل
کدها روی پلتفرمهایی مانند اندروید و iOS در نظر گرفته شده، کامپایل برنامه را با در
نظر گرفتن مواردی مثل کمبود حافظه در دستگاههای همراه انجام میدهد.
نقش متادیتای نوع در یک اسمبلی
همانطور که گفتیم، یک اسمبلی .NET علاوه بر کدهای CIL شامل متادیتای نوع یا Type
Metadata است که اطلاعات دقیق و کاملی در مورد نوعهای استفادهشده در برنامه مانند
کلاسها، اینترفیسها، ساختارها و اعضای این نوعها مانند پراپرتیها، متدها و رویدادها
ارائه میدهد. خوشبختانه تولید متادیتای نوع به عهدهی کامپایلر است نه برنامهنویس. برای
دیدن فرمت متادیتای نوع، اجازه دهید نگاهی به متادیتای تولید شده برای متد Add()
از کلاس Calc در مثال ماشینحساب بیندازیم.
// TypeDef #2 (02000003)
//
-------------------------------------------------------
// TypDefName: Calc
(02000003)
// Flags : [NotPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]
(00100000)
// Extends : 0100000D [TypeRef] System.Object
// Method #1
(06000003)
// -------------------------------------------------------
//
MethodName: Add (06000003)
// Flags : [Public] [HideBySig] [ReuseSlot]
(00000086)
// RVA : 0x00002090
// ImplFlags : [IL] [Managed] (00000000)
//
CallCnvntn: [DEFAULT]
// hasThis
// ReturnType: I4
// 2 Arguments
//
Argument #1: I4
// Argument #2: I4
// 2 Parameters
// (1) ParamToken :
(08000002) Name : addend1 flags: [none] (00000000)
// (2) ParamToken : (08000003)
Name : addend2 flags: [none] (00000000)
متادیتا توسط بخشهای مختلفی از Runtime و نیز ابزارهای توسعه و خود کامپایلر مورد استفاده
قرار میگیرد. به عنوان مثال، قابلیت تکمیل خودکار کدها یا Intellisense در ویژوال استودیو
با خواندن متادیتا در زمان طراحی برنامه ممکن میشود. در فصل هفدهم خواهید دید که متادیتای
نوع، ستون فقرات تکنولوژیهایی مانند Reflection و Serialization است.
نقش مانیفست در یک اسمبلی
مانیفست، متادیتایی است که خودِ اسمبلی را توصیف میکند و شامل اطلاعاتی در مورد اسمبلی از
جمله ورژن اسمبلی، اسمبلیهای اکسترنال مورد ارجاع توسط این اسمبلی، اطلاعات مربوط به
کپیرایت و غیره میباشد. تولید مانیفست نیز مانند متادیتای نوع بر عهدهی کامپایلر است. در
اینجا مانیفست تولید شده برای مثال ماشینحساب را میبینیم.
.assembly extern /*23000001*/
System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....:
.ver
6:0:0:0
}
.assembly extern /*23000002*/ System.Console
{
.publickeytoken =
(B0 3F 5F 7F 11 D5 0A 3A ) // .?_....:
.ver 6:0:0:0
}
.assembly /*20000001*/
Calc.Cs
{
.hash algorithm 0x00008004
.ver 1:0:0:0
}
.module
Calc.Cs.dll
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve
0x00100000
.subsystem 0x0003 // WINDOWS_CUI
.corflags 0x00000001 // ILONLY
در فصل شانزدهم، دلایل اهمیت مانیفست و نقش آن با جزئیات بیشتری بیان میشود.
مروری بر فضاهای نام .NET
یادآوری میکنم که کتابخانه کلاسهای پایه یا BCL مجموعهای است شامل کتابخانههای کد
(code
libraries) که به تسهیل فرایند برنامهنویسی کمک میکنند. با این حال، باید بدانید که این
کتابخانهها نه متعلق به C# بلکه متعلق به .NET هستند. برای
سازماندهی نوعهای موجود در BCL، پلتفرم .NET از مفهومی به نام فضای نام
(namespace) استفاده میکند.
یک فضای نام مجموعهای است از نوعهای مرتبط. نوعهای یک فضای نام میتوانند در یک اسمبلی
قرار داشته باشند یا در چند اسمبلی پراکنده باشند. به عنوان مثال، فضای نام System.IO شامل
نوعهای مرتبط با سیستم فایل و دایرکتوری است و فضای نام System.Data نوعهای مربوط به
پایگاه داده را شامل است. پس هر اسمبلی دارای تعدادی فضای نام است و هر فضای نام از چندین
نوع مرتبط تشکیل شده است.
دسترسی به فضاهای نام از طریق کد
تکرار میکنم که یک فضای نام چیزی نیست مگر یک مجموعه از نوعهای مرتبط با هم که بر اساس یک
طبقهبندی منطقی در یک گروه قرار گرفتهاند تا دسترسی و کار با نوعها را برای ما سادهتر
کند. برای مثال، System.Console برای ما نشانگر کلاسی با نام Console
در فضای نام System
است اما از تظر Runtime فقط نشانگر کلاسی با نام System.Console است.
وقتی بخواهیم از کلاسی مثل System.Console استفاده کنیم، باید همانند زیر از نام کامل این
کلاس استفاده کنیم:
System.Console.WriteLine("Hello, World!");
اما میتوانیم با استفاده از کلمه کلیدی using مانند زیر فضای نام System را به برنامه معرفی
کنیم تا بتوانیم از کلاس Console (و هر نوع دیگر در این فضای نام) بدون نیاز به پیشوند فضای
نام استفاده کنیم:
using System;
Console.WriteLine("Hello, World!");
هر دو روش، کارایی یکسانی دارند و منجر به تولید کد CIL یکسانی میشوند.
فضاهای نام علاوه بر سازماندهی و گروهبندی نوعها، از تداخل نامها نیز جلوگیری میکنند.
چون دو نوع که در دو فضای نام مختلف قرار داشته باشند، میتوانند نام یکسانی داشته باشند.
با معرفی .NET 6 و C# 10 چند ویژگی جدید مربوط به فضاهای نام و
استفاده از آنها در برنامههای .NET معرفی شد که در ادامه به آنها اشاره
میکنیم.
گزارههای Global using
وقتی برنامههای بزرگتر و پیچیدهتری با استفاده از C# ایجاد میکنیم، احتمالاً
فضاهای نامی داریم که در فایلهای متعددی تکرار میشوند. در C# 10 این امکان
فراهم شده که به یک فضای نام به صورت Global ارجاع دهیم تا در همهی فایلهای پروژه در
دسترس باشد. برای این کار، کافیست عبارت global را به ابتدای یک گزارهی using اضافه کنیم:
با قرار دادن این گزاره در فایل Program.cs فضای نام System در همهی فایلهای پروژه به
صورت
ضمنی اضافه شده و به نوعهای آن دسترسی خواهیم داشت.
توجه داشته باشید که همهی گزارههای global using باید قبل از سایر
گزارههای using آورده شوند.
گزارههای global using را علاوه بر فایل Program.cs (یا هر فایل مجزا) میتوان با استفاده
از فرمت زیر در فایل پروژه نیز قرار داد:
<ItemGroup>
<Using Include=”System.Text”
/>
<Using Include =”System.Text.Encodings.Web”/>
<Using
Include=”System.Text.Json” />
<Using
Include=”System.Text.Json.Serialization” />
</ItemGroup>
گزارههای ضمنی Global using
یکی دیگر از ویژگیهای جدیدی که در .NET 6 و C# 10 معرفی شد،
گزارههای ضمنی global using هستند. بسته به نوع اپلیکیشنی که در حال ساخت آن هستیم، تعدادی
گزارهی global using به طور ضمنی به پروزه اضافه شده است. بنابراین، میتوانیم بدون اینکه
هیچ گزارهی using را به صورت دستی ایجاد کنیم، به نوعهای چند فضای نام دسترسی داشته
باشیم.
برای دیدن لیست این گزارهها برای پروژهی خود، میتوانیم فایل <ProjectName>
GlobalUsings.g.cs در پوشهی \obj\Debug\net6.0 را ببینیم.
فضاهای نام File Scoped
یکی دیگر از قابلیتهای جدید C# 10 که به فضاهای نام مربوط است، فضاهای نام File
Scoped است. تا قبل از C# 10 برای قرار دادن یک کلاس در یک فضای نام باید بعد از
اعلان فضای نام، یک جفت آکلاد ایجاد کرده و کد کلاس را بین آکلادها قرار میدادیم. مانند
زیر:
namespace CalculatorExamples
{
class Calculator
{
...
}
}
اما از C# 10 به بعد، میتوانیم از ویژگی File Scoped Namespaces مانند زیر
استفاده کنیم:
namespace CalculatorExamples
class Calculator
{
...
}
فضاهای نام سفارشی به طور کامل در فصل شانزدهم بررسی میشوند.