پرش به مطلب اصلی
مهدی بهرامیان
مهدی بهرامیان
کارشناسی ۱۴۰۱

زبان Zig: فراتر از C با تمرکز بر کارایی و شفافیت کد

حدود یک‌‌سال می‌شود که با زبان zig آشنا شده‌ام، زبانی که ادعا می‌کند که «یک C بهتر» است و از وقتی با آن آشنا شده‌ام، دیدم را نسبت به خیلی از ابعاد برنامه‌نویسی گسترش داده‌‌است. این زبان ابعاد بسیار گسترده‌ای دارد، اما برای این متن تصمیم گرفتم که با تمرکز بر «مدیریت حافظه»، «Comptime» و «System build» در zig برای‌تان بنویسم.

مدیریت حافظه

یکی از جنبه‌های مهم هر زبان برنامه‌نویسی، نحوۀ برخورد با حافظه است؛ چرا که در رایانه‌های امروزی، چه در پردازنده‌های گرافیکی و چه در پردازنده‌های مرکزی، با مسئلهٔ حافظه دست‌و‌پنجه نرم می‌کنیم و گلوگاه اصلی هر برنامه یا پردازنده، سرعت دسترسی به حافظه است.

یکی از ایده‌های اساسی این زبان این است که هیچ «تخصیص‌دهندۀ حافظۀ»Memory Allocator مرکزی‌ای وجود ندارد! در عوض، این مفهوم به‌عنوان یک درگاهInterface مشتق‌شده از توابع alloc و free تعریف می‌شود که پیاده‌سازی‌های متفاوتی در کتابخانه استاندارد دارد. چند پیاده‌سازی این درگاه را در زیر مشاهده می‌کنید:‌

  • std.heap.PageAllocator: تخصیص‌دهنده‌ای که به ازای هر درخواست، به‌طور مستقیم فراخوانی‌های سیستمی مرتبط با محیط اجرای برنامه را صدا می‌زند. به‌طور مثال در محیط posix از mmap و munmap استفاده می‌کند.

  • std.heap.SmpAllocator: تخصیص‌دهنده‌ای که از نظر فلسفه و عملکرد مشابه malloc در زبان c عمل می‌کند.

  • std.heap.DebugAllocator: تخصیص‌دهنده‌ای که به برنامه‌نویس در دیباگ کردن مشکلات حافظه مانند «آزاد کردن مجدد» و «نشت منابع» کمک می‌کند و به‌طور دقیق محل وقوع این مشکلات را گزارش می‌کند.

  • std.heap.ArenaAllocator: تخصیص‌دهنده‌ای که مانند یک پشته عمل می‌کند. با استفاده از این تخصیص‌دهنده می‌توانید به سادگی حافظه را مدیریت کنید؛ به این صورت که به جای این‌که نگران طول عمر تک‌تک تخصیص‌ها باشید، می‌توانید به‌طور یک‌جا برای یک Arena طول عمر تعیین کنید. به این صورت، هم طراحی برنامۀ صحیح راحت‌تر می‌شود و هم در بسیاری از موارد شاهد تسریع عملکرد خواهیم بود؛ چرا که تخصیص‌دهندۀ اصلی برنامه نیازی نیست به هزاران یا میلیون‌ها تخصیص خرد فکر کند و آن‌ها را نگه دارد. در عوض کافی است تعدادی محدود از Arenaها را نگه دارد.

  • std.Heap.FixedBufferAllocator: تخصیص‌دهنده‌ای که قطعاتی از یک حافظۀ ثابت را به دیگران تخصیص می‌دهد. از این تخصیص‌دهنده می‌توانید استفاده کنید تا هر تخصیص حافظۀ دلخواهی را به پشتۀ برنامه منتقل کنید یا این‌که در ابتدای برنامه از روی heap یک قطعه حافظه بگیرید و در ادامه از آن استفاده کنید و در میانۀ برنامه هیچ تخصیص حافظه‌ای نداشته باشید. به‌طور مثــــال، TigerBeetle (ســـــریع‌تــرین و پایدارترین پایگاه دادهٔ تراکنشی) تمامی تخصیص‌های حافظه را در ابتدای برنامه انجام می‌دهد. 

به این شکل، کتابخانه‌ها مستقل از یک تخصیص‌دهنده، ثابت خواهند شد و مسئولیت این کار بر عهدۀ استفاده‌کننده قرار می‌گیرد. برای مثال، شما می‌توانید از همۀ کتابخانه‌ها، حتی بدون وجود heap استفاده کنید. این معماری می‌تواند به بهبود کارایی و افزایش قابلیت انتقال برنامه‌های شما کمک شایانی کند. به‌طور مثال، شما می‌توانید از هر کتابخانه‌ای روی سامانه‌های نهفتهEmbedded Systems استفاده کنید.

ورودی/خروجی نیز در نسخهٔ ۰.۱۵ و ۰.۱۶ مانند Allocator خواهد شد و در حال حاضر، اجرای کتابخانه‌هایی که از ورودی و خروجی استفاده می‌کنند روی این سامانه‌ها ممکن نیست اما با کامل شدن درگاه ورودی/خروجی این امر میسر خواهد شد.

Comptime

یکی از وجوه تمایز zig نسبت به بسیاری از زبان‌های دیگر این مورد است که می‌توان تقریباً هر کدی را که با ورودی/خروجی و عملیات‌های وابسته به یک بستر خاص (مانند اسمبلی) کاری نداشته باشد، در زمان کامپایل اجرا کرد. علاوه‌بر این، zig به شما امکان انجام «انعکاس»Reflection روی انواع دادهData Types را می‌دهد و به این صورت می‌توانید ویژگی‌های مختلف مانند «فیلدها»Fields و «متدها»Methods را بررسی کنید، مقادیر آن‌ها را تغییر دهید یا بخوانید. این قابلیت، اجرای الگو‌های «طراحی داده‌محور» را بسیار ساده می‌کند، بدون آن‌که نگران صحت عملکرد برنامه باشید؛ چرا که تبدیل کد به حالت داده‌محور آن، در زمان کامپایل و توسط الگوریتم شما انجام می‌شود. در ادامه، چند مثال از این ابزار قدرتمند zig را مشاهده می‌کنید:

  • اجرای تابع فیبوناچی در زمان کامپایل
const std = @import("std");
pub fn main() !void {
const fib10 = comptime blk: {
var fib = [_]usize{ 1, 1 };
for (0..5) |_| {
const tmp_fib = fib;
fib = .{ tmp_fib[1], tmp_fib[0] + tmp_fib[1] };
}
break :blk fib[0];
};
std.debug.print("{}", .{fib10});
}
  • داده‌ساختارهای کلی‌سازی‌شده
const std = @import("std");
pub fn main() !void {
const T = GenericType(i32, usize);
try T.printTypes();
}

fn GenericType(T0: type, T1: type) type {
return struct {
a: T0,
b: T1,
pub fn printTypes() !void {
std.debug.print("{s} and {s}\n", .{ @typeName(T0), @typeName(T1) });
}
};
}
  • چندریختی ایستا
const std = @import("std");
pub fn main() !void {
const x = Cat{};
const y = Dog{};
const a0 = Animal.init(x);
const a1 = Animal.init(y);
a0.sound();
a1.sound();
}

const Cat = struct {
pub fn sound(_: Cat) void {
std.debug.print("meaw!!\n", .{});
}
};

const Dog = struct {
pub fn sound(_: Dog) void {
std.debug.print("woof!!\n", .{});
}
};

const Animal = union(enum) {
cat: Cat,
dog: Dog,
pub fn init(self: anytype) Animal {
const options = std.meta.fields(Animal);
inline for (options) |fld| {
if (@TypeOf(self) == fld.type)
return @unionInit(Animal, fld.name, self);
}
@compileError("In compatible type of self");
}
pub fn sound(self: Animal) void {
switch (self) {
inline else => |x| {
x.sound();
},
}
}
};

برای مشاهدۀ مثال‌های بیشتر، می‌توانید به کتابخانۀ استاندارد zig مراجعه کنید. توابعی مانند std.fmt.print به‌طور کامل درون خود زبان پیاده‌سازی شده‌اند و نیازی به از پیش تعیین شدن‌شان درون کامپایلر نیست. همچنین، برای مشاهدۀ یک نمونۀ داده‌محور، می‌توانید به std.MultiArrayList مراجعه کنید و همین‌طور مقالات و ارائه‌های «اندرو کلی»Andrew Kelley -سازندهٔ zig- را مطالعه نمایید.

نگهداری از پروژه‌ها با استفاده از zig

همان‌طور که گفته شد، zig قصد دارد جایگزین c شود. بدیهی است که نمی‌توان میلیون‌ها و شاید میلیارد‌ها خط کد c و cpp موجود در دنیا را نادیده گرفت. از این رو، zig، خود یک کامپایلر c و cpp است و از نظر سهولت استفاده، بسیار راحت‌تر از clang و gcc عمل می‌کند و پیش‌فرض‌های معقول‌تر و بهینه‌تری نسبت به آن‌ها دارد. 

روشی که zig برای دستیابی به این هدف در پیش گرفته، این است که علاوه‌بر بهره‌گیری از LLVM (که پشتوانهٔ اکثر کامپایلرهای نوین است)، کل کتابخانهٔ libcpp را نیز درون خود دارد و در عمل می‌تواند تابع main خود کامپایلر Clang را فراخوانی کند! علاوه‌بر این، zig مشکل دیرینهٔ سیستم‌های ساختBuild Systems در C و ++C را با یکپارچه‌سازی فرآیندهای Caching و مدیریت پکیج‌ها در دو سطح سیستم و کد منبع حل کرده‌است. در نتیجه، با استفاده از zig به‌راحتی می‌توانید پروژهٔ خود را از هر سیستم‌عاملی به هر سیستم هدفی کامپایل کنید و این قابلیت، چالش‌های Cross Compile در C و ++C را تا حد بسیار زیادی مرتفع ساخته است.

ساختار سیستم ساختSystems Build zig به این صورت است که کافی است یک فایل build.zig برای توصیف فرایند ساخت، دستورات و گزینه‌های موجود شامل یک تابع build داشته باشید. همچنین، برای مشخص کردن نام، برخی ویژگی های پروژه و وابستگی‌های آن، یک فایل build.zig.zon نیاز است. به علاوه، zig آماده‌سازی اولیۀ این محیط را برای شما با استفاده از دستور  zig init انجام می‌دهد و با استفاده از دستور zig fetch --save {PKG-PATH} می‌توانید بسته‌های دلخواه خود را به پروژه اضافه کنید.  به‌طور مثال، شما می‌توانید با استفاده از دستور زیر، build-utils را از گیت بگیرید: 

zig fetch --save="build-util"
git+https://github.com/MahdiGMK/build-util.zig.git

و با استفاده از کد build.zig زیر یک باینری به نام exe با لینک کردن تمام فایل‌های cpp. درون پوشهٔ src به‌عنوان فایل‌های به زبان cpp. توصیف می‌شود: 

const std = @import("std");
const util = @import("build-util");

pub fn build(b: *std.Build) !void {
const targ = b.standardTargetOptions(.{});
const optim = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "exe",
.root_module = b.createModule(.{
.target = targ,
.optimize = optim,
.link_libcpp = true,
}),
});
const srcdir = try b.build_root.handle.openDir("src/",
.{ .iterate = true });

exe.root_module.addCSourceFiles(.{
.language = .cpp,
.root = b.path("src"),
.files = try util.listFilesRecursive(b.allocator, 16, srcdir, ".cpp"),
});
exe.root_module.addIncludePath(b.path("src/"));
b.installArtifact(exe);
}

زبان zig، یک زبان برنامه‌نویسی متن‌بازOpen Source است که طبق ادعای سایت رسمی آن، با اهدافی همچون قدرت بالا، بهینه‌ بودن و همچنین وضوح سورس‌کد به‌صورت متن‌باز توسعه یافته‌است. zig توسط اندرو کلی در سال ۲۰۱۵ شروع شد و اکنون به نظر می رسد که به حجم بحرانیCritical Mass لازم رسیده‌است. جاه‌طلبی zig در تاریخ نرم‌افزار بسیار مهم است: تبدیل شدن به وارث سلطنت طولانی‌مدت C به‌عنوان زبانی سطح پایین، قابل‌حمل و به‌عنوان استانداردی که زبان‌های دیگر با آن سنجیده می‌شوند.