الأدلة التقنية

أنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟

نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.

أنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟

تبدأ تعلم البرمجة بلغة واحدة، ثم تفتح أول مشروع في العمل فتجد الواجهة الأمامية بلغة TypeScript، والخدمة الخلفية بلغة Go، وسكربتات الأتمتة بلغة Python، وتقارير قاعدة البيانات بلغة SQL، وملفات البنية التحتية بلغة Terraform، ثم تقرأ في الإعلانات الوظيفية كلمات مثل «لغة وظيفية Functional» و«أنواع ثابتة Static Types» و«Null Safety» وكأن كل مطور يعرف معناها. وقد يتساءل البعض: لماذا كل هذه اللغات أصلاً؟ ولماذا لا يتفق المطورون على لغة واحدة ويرتاح الجميع؟ والإجابة أن كل لغة هي مجموعة قرارات صممها أصحابها لحل نوع معين من المشاكل، فاللغة التي تجعل كتابة سكربت سريع أمراً سهلاً هي نفسها التي تترك بعض الأخطاء حتى وقت التشغيل، واللغة التي تمسك أغلب الأخطاء قبل التشغيل تطلب منك في المقابل أن تكتب أكثر وتنتظر المترجم.

وهذه القرارات لها أسماء، وأهمها سؤالان: الأول هو نمط البرمجة Programming Paradigm، أي هل تكتب للحاسوب خطوات ينفذها واحدة بعد الأخرى، أم تصف له النتيجة التي تريدها وتتركه يقرر الطريقة. والثاني هو نظام الأنواع Type System، أي متى تكتشف اللغة أنك تجمع نصاً مع رقم، وماذا تفعل عندها. وسوف نناقش في هذا المقال ما يلي:

  • لماذا توجد مئات اللغات، وما هي المقايضات Trade-offs التي تختلف فيها.
  • أنماط البرمجة بمثال واحد يتكرر في كل نمط، وهو حساب إجمالي طلب في متجر: الإجرائي Procedural بلغة Go، والكائني Object-Oriented بلغة Java، والوظيفي Functional بلغة JavaScript وPython وHaskell، والتصريحي Declarative بلغة SQL وTerraform، والمنطقي Logic بلغة Prolog، والقائم على الأحداث Event-Driven.
  • أنظمة الأنواع: الثابتة Static والديناميكية Dynamic، والقوية Strong والضعيفة Weak، واستنتاج الأنواع Type Inference، والأنواع التدريجية Gradual Typing مع TypeScript وmypy، والنظام الاسمي Nominal والبنيوي Structural، والحماية من Null، والأنواع العامة Generics.
  • جدول مقارنة لأشهر اللغات من حيث الأنماط والأنواع وطريقة التنفيذ وإدارة الذاكرة، ثم كيف تستفيد من هذا كله عند اختيار لغة لمشروعك.

وكل مثال في المقال شغلناه فعلاً داخل Container، والمخرجات والأخطاء المنسوخة هنا هي ما ظهر على الشاشة كما هو.

لماذا توجد كل هذه اللغات؟

لنفرض أنك تريد لغة واحدة تناسب كل شيء، فسوف تطلب منها أن تكون سريعة جداً مثل C لأنك تكتب بها نظام تشغيل، وأن تكون سهلة ومرنة مثل Python لأنك تكتب بها سكربت لمرة واحدة، وأن تمنع أخطاء الذاكرة مثل Rust لأنك تكتب بها برنامجاً يتعامل مع بيانات الناس، وأن تعمل داخل المتصفح مثل JavaScript. والمشكلة أن هذه الطلبات تتعارض، فالسرعة القصوى تحتاج تحكماً مباشراً في الذاكرة، والتحكم المباشر يفتح باب الأخطاء، ومنع الأخطاء قبل التشغيل يحتاج قواعد صارمة يفحصها المترجم Compiler، والقواعد الصارمة تبطئ كتابة السكربت السريع. لذلك فكل لغة تختار نقطة على هذه المقايضات، وأهمها ما يلي:

  • سرعة التنفيذ Performance مقابل سرعة الكتابة Productivity: لغة مثل C تعطيك أقصى سرعة وتترك لك كل التفاصيل، ولغة مثل Python تكتب بها في سطر ما يحتاج عشرة أسطر في C، ولكنها أبطأ في التنفيذ.
  • الأمان Safety مقابل الحرية: كلما زادت القواعد التي يفحصها المترجم قلت الأخطاء التي تصل إلى المستخدم، وزاد الوقت الذي تقضيه في إرضاء المترجم.
  • البيئة التي تعمل فيها: JavaScript موجودة لأنها اللغة التي تفهمها المتصفحات، وSwift وKotlin موجودتان لأن Apple وGoogle تدعمانهما في أنظمة الهواتف، وSQL موجودة لأن قواعد البيانات تفهمها.
  • طريقة التفكير في المشكلة: بعض المشاكل تكتب بوضوح كخطوات، وبعضها كتحويل بيانات، وبعضها كقواعد منطقية، ولهذا ظهرت الأنماط التي سوف نشرحها.

وقصة كل لغة ومن صنعها ولماذا، تجدها في مقال قصص لغات البرمجة ومن صنعها، أما الفرق بين اللغة كمواصفات Specification وبين البرامج التي تنفذها مثل CPython وPyPy، ومن يقرر مستقبل كل لغة، فهو موضوع مقال مواصفات لغات البرمجة وتطبيقاتها وحوكمتها. وفي هذا المقال سوف نركز على الفكرتين اللتين تفرقان بين اللغات في الكود الذي تكتبه كل يوم: الأنماط والأنواع.

المختبر: كل اللغات في Containers

لن تحتاج إلى تثبيت أي لغة على جهازك، فكل مثال يعمل داخل Container من Docker Image رسمية، يحذف بعد انتهاء الأمر بسبب الخيار --rm، ويقرأ الملفات من المجلد الحالي عبر -v "$PWD":/w. وإذا لم يكن Docker مثبتاً لديك فقد شرحنا تثبيته في دليل تثبيت Docker على Ubuntu. والجدول التالي يبين ال Images والإصدارات التي استخدمناها:

اللغة أو الأداةDocker Imageالإصدار الذي ظهر في المختبر
Gogolang:1.27.1-alpine3.24go1.27.1
Javaeclipse-temurin:25-jdk-alpineOpenJDK 25.0.4.1
JavaScript وTypeScriptnode:22-alpineNode.js v22.23.3 وTypeScript 7.0.2
Python وmypypython:3.13-slimPython 3.13.16 وmypy 2.4.0
Rust وCrust:1-alpinerustc 1.99.0 وgcc 15.2.0
Haskellhaskell:9-slimGHC 9.14.1
Prologswipl:latestSWI-Prolog 10.1.16
SQLpostgres:17.11-alpinePostgreSQL 17.11
Terraformhashicorp/terraform:latestTerraform v1.16.5

ولاحظ أن كل Container يأخذ اسماً يبدأ ب lab-lang، والسبب أنه إذا بقي أحدها يعمل لأي سبب فسوف تجده بسهولة وتحذفه وحده دون أن تلمس Containers المشاريع الأخرى على جهازك.

أنماط البرمجة Programming Paradigms: الخطوات أم النتيجة؟

نمط البرمجة Programming Paradigm هو طريقة التفكير التي تكتب بها الحل، وليس صياغة Syntax لغة معينة، فيمكنك أن تكتب بأكثر من نمط في نفس اللغة. والتقسيم الأشهر يبدأ بعائلتين: العائلة الأمرية Imperative، وفيها تقول للحاسوب كيف يصل إلى النتيجة خطوة بعد خطوة، وتغير قيم المتغيرات في الطريق، ومنها الإجرائية Procedural والكائنية Object-Oriented. والعائلة التصريحية Declarative، وفيها تصف النتيجة التي تريدها وتترك للغة أو للنظام تحديد الخطوات، ومنها الوظيفية Functional والمنطقية Logic ولغات الاستعلام والوصف مثل SQL وHTML. والصورة التالية تبين هذه العائلة:

شجرة تبدأ بأنماط البرمجة وتتفرع إلى Imperative وتحتها Procedural مثل C وGo وObject-oriented مثل Java وC#، وإلى Declarative وتحتها Functional مثل Haskell وLogic مثل Prolog وQuery and config مثل SQL وHTML وHCL، وتحتها شريط للغات متعددة الأنماط مثل Python وJavaScript وKotlin وRust، وشريط للبرمجة القائمة على الأحداث
العائلة الأمرية تصف الخطوات، والتصريحية تصف النتيجة، وأغلب اللغات الحديثة تجمع أكثر من نمط

ولكي تكون المقارنة عادلة سوف نحل نفس المشكلة في كل نمط، وهي حساب إجمالي طلب في متجر فيه أربعة أسطر: قهوة بسعر 12 والكمية 2، وكعكة بسعر 18 والكمية 1، وعصير بسعر 9 والكمية 0 لأن العميل ألغاه، وماء بسعر 3 والكمية 4. والإجمالي الصحيح هو 24 + 18 + 12 = 54، وسطر العصير يجب أن يتجاهل.

البرمجة الإجرائية Procedural: خطوات تغير متغيراً

البرمجة الإجرائية Procedural Programming هي أقرب الأنماط لطريقة عمل المعالج نفسه: متغيرات في الذاكرة، وأوامر تنفذ بالترتيب، وحلقات Loops وشروط، ودوال Procedures تجمع الخطوات المتكررة. وهذا هو الحل بلغة Go في الملف main.go:

package main

import "fmt"

type Line struct {
	Name  string
	Price int
	Qty   int
}

func main() {
	order := []Line{
		{"coffee", 12, 2},
		{"cake", 18, 1},
		{"juice", 9, 0},
		{"water", 3, 4},
	}

	total := 0
	for i := 0; i < len(order); i++ {
		if order[i].Qty > 0 {
			total = total + order[i].Price*order[i].Qty
			fmt.Println("after", order[i].Name, "total =", total)
		}
	}
	fmt.Println("order total:", total)
}

ونشغله بالأمر التالي:

docker run --rm --name lab-lang-go -v "$PWD":/w -w /w golang:1.27.1-alpine3.24 go run main.go

والمخرج سوف يكون كما يلي:

after coffee total = 24
after cake total = 42
after water total = 54
order total: 54

في الكود أعلاه لاحظ التالي:

  • المتغير total يبدأ من صفر ثم تتغير قيمته في كل دورة من الحلقة، وهذا ما يسمى الحالة القابلة للتغيير Mutable State، وهي قلب البرمجة الأمرية.
  • أنت من يحدد كل شيء: من أين تبدأ الحلقة، ومتى تتوقف، وكيف يتخطى الكود سطر العصير، ولهذا السبب فالكود واضح لمن يقرؤه خطوة خطوة، ولكنه أيضاً يفتح الباب لأخطاء مثل أن تبدأ العداد من 1 بدلاً من 0 فيضيع أول سطر.
  • الأسطر المطبوعة تبين الحالة بعد كل خطوة: 24 ثم 42 ثم 54، ولا يوجد سطر للعصير لأن الشرط Qty > 0 تخطاه.

ولغة C وPascal وGo هي أمثلة واضحة على هذا النمط، وإن كانت Go تضيف الواجهات Interfaces التي سوف نراها لاحقاً، ولا تحتوي على الوراثة Inheritance كما تقول الأسئلة الشائعة للغة Go.

البرمجة الكائنية OOP: بيانات وسلوك في مكان واحد

عندما يكبر البرنامج تظهر مشكلة في الأسلوب الإجرائي، وهي أن البيانات (أسطر الطلب) في مكان، والدوال التي تعدلها في مكان آخر، وأي جزء من الكود يستطيع أن يضع كمية سالبة في الطلب دون أن يمنعه أحد. والبرمجة الكائنية Object-Oriented Programming واختصاراً OOP تحل هذا بأن تجمع البيانات والدوال التي تعمل عليها في كلاس Class واحد، وتخفي البيانات بحيث لا تتغير إلا عبر دوال الكلاس. والمثال التالي بلغة Java في الملف Main.java:

import java.util.ArrayList;
import java.util.List;

interface Discount {
    int apply(int total);
}

class NoDiscount implements Discount {
    public int apply(int total) { return total; }
}

class PercentOff implements Discount {
    private final int percent;
    PercentOff(int percent) { this.percent = percent; }
    public int apply(int total) { return total - total * percent / 100; }
}

class Order {
    private final List<int[]> lines = new ArrayList<>();
    private final Discount discount;

    Order(Discount discount) { this.discount = discount; }

    void add(int price, int qty) {
        if (qty < 0) throw new IllegalArgumentException("qty must be >= 0");
        lines.add(new int[] {price, qty});
    }

    int total() {
        int sum = 0;
        for (int[] l : lines) sum += l[0] * l[1];
        return discount.apply(sum);
    }
}

public class Main {
    public static void main(String[] args) {
        for (Discount d : List.of(new NoDiscount(), new PercentOff(10))) {
            Order order = new Order(d);
            order.add(12, 2);
            order.add(18, 1);
            order.add(9, 0);
            order.add(3, 4);
            System.out.println(d.getClass().getSimpleName() + " -> " + order.total());
        }
        new Order(new NoDiscount()).add(5, -1);
    }
}

ونشغله بالأمر التالي، ولاحظ أن Java منذ الإصدار 11 تستطيع تشغيل ملف المصدر مباشرة دون خطوة javac منفصلة كما في JEP 330:

docker run --rm --name lab-lang-java -v "$PWD":/w -w /w eclipse-temurin:25-jdk-alpine java Main.java

والمخرج سوف يكون كما يلي:

NoDiscount -> 54
PercentOff -> 49
Exception in thread "main" java.lang.IllegalArgumentException: qty must be >= 0
	at Order.add(Main.java:25)
	at Main.main(Main.java:46)

في الكود أعلاه لاحظ التالي:

  • التغليف Encapsulation: القائمة lines معرفة private، فلا يستطيع أي كود خارج الكلاس Order أن يضيف إليها مباشرة، والطريق الوحيد هو الدالة add التي ترفض الكمية السالبة، ولهذا ظهر الاستثناء IllegalArgumentException في آخر سطر من المخرج عندما حاولنا إضافة كمية -1.
  • تعدد الأشكال Polymorphism: الكلاس Order لا يعرف نوع الخصم، فهو يتعامل مع الواجهة Discount فقط، ونفس الاستدعاء discount.apply(sum) أعطى 54 مع NoDiscount و49 مع PercentOff(10)، والسبب في 49 وليس 48.6 أن القسمة هنا على أعداد صحيحة Integers فيقطع الكسر.
  • التركيب Composition مقابل الوراثة Inheritance: الطلب «لديه» خصم Has-a، ولم نجعل DiscountedOrder كلاساً يرث من Order. والسبب أن الوراثة تربط الكلاس الابن بتفاصيل الأب، فإذا احتجت طلباً بخصم وبشحن مجاني وبضريبة فسوف تنتهي بشجرة كلاسات لكل تركيبة، أما التركيب فتمرر فيه الكائنات التي تريدها. والوراثة لها مكانها عندما تكون العلاقة «هو نوع من» Is-a فعلاً، كما يشرح درس الوراثة في توثيق Oracle.

ولاحظ أن الكائنية ما زالت أمرية في داخلها، فالدالة total() فيها حلقة تغير المتغير sum تماماً كما في Go، والفرق هو تنظيم الكود حول الكائنات وحماية حالتها.

البرمجة الوظيفية Functional: دوال تحول البيانات

البرمجة الوظيفية Functional Programming تنظر إلى الحساب على أنه تطبيق دوال على بيانات لإنتاج Production بيانات جديدة، بدلاً من تغيير متغيرات. وفكرتها الأساسية ثلاث أدوات: filter تختار العناصر التي تحقق شرطاً، وmap تحول كل عنصر إلى قيمة جديدة، وreduce تجمع القائمة في قيمة واحدة. والمثال التالي بلغة JavaScript في الملف order.js:

const order = [
  { name: "coffee", price: 12, qty: 2 },
  { name: "cake",   price: 18, qty: 1 },
  { name: "juice",  price: 9,  qty: 0 },
  { name: "water",  price: 3,  qty: 4 },
];

const lineTotal = (line) => line.price * line.qty;   // pure function
const inStock   = (line) => line.qty > 0;

const total = order
  .filter(inStock)
  .map(lineTotal)
  .reduce((sum, x) => sum + x, 0);

console.log("order total:", total);
console.log("order unchanged:", order.length, "lines");

const applyTwice = (f, x) => f(f(x));                // higher-order function
console.log(applyTwice((n) => n * 2, 5));

const frozen = Object.freeze([1, 2, 3]);
try { frozen.push(4); } catch (e) { console.log(e.constructor.name + ":", e.message); }

ونشغله بالأمر التالي:

docker run --rm --name lab-lang-node -v "$PWD":/w -w /w node:22-alpine node order.js

والمخرج سوف يكون كما يلي:

order total: 54
order unchanged: 4 lines
20
TypeError: Cannot add property 3, object is not extensible

في الكود أعلاه لاحظ التالي:

  • الدوال النقية Pure Functions: الدالة lineTotal تعطي نفس النتيجة دائماً لنفس المدخل، ولا تغير شيئاً خارجها، ولهذا يسهل اختبارها وحدها، ويسهل تشغيلها على عدة أنوية دون أقفال، وهو موضوع شرحناه في مقال المعالج والخيوط والتزامن.
  • عدم التغيير Immutability: بعد كل الحساب ما زال الطلب الأصلي فيه 4 أسطر، لأن filter وmap تنتجان قوائم جديدة ولا تعدلان القائمة الأصلية. والسطر الأخير يبين أن Object.freeze يمنع التعديل فعلاً، فمحاولة push على المصفوفة المجمدة رمت TypeError، كما يوضح توثيق MDN.
  • الدوال عالية الرتبة Higher-Order Functions: applyTwice تستقبل دالة كمعامل وتستدعيها مرتين، فأعطت 5 × 2 × 2 = 20. وهذا ممكن لأن الدوال في JavaScript قيم عادية من الدرجة الأولى First-Class Functions كما في MDN، وfilter وmap وreduce نفسها دوال عالية الرتبة.

ونفس الفكرة في Python، مرة بالدوال map وfilter وreduce، ومرة بالطريقة التي يفضلها مطورو Python وهي التعبير المولد Generator Expression، في الملف order.py:

from functools import reduce

order = (("coffee", 12, 2), ("cake", 18, 1), ("juice", 9, 0), ("water", 3, 4))

total = reduce(lambda s, x: s + x,
               map(lambda l: l[1] * l[2],
                   filter(lambda l: l[2] > 0, order)), 0)
print("reduce:", total)
print("sum + generator:", sum(p * q for _, p, q in order if q > 0))
docker run --rm --name lab-lang-py -v "$PWD":/w -w /w python:3.13-slim python order.py

والمخرج سوف يكون كما يلي:

reduce: 54
sum + generator: 54

أما Haskell فهي لغة وظيفية نقية Purely Functional كما تصف نفسها في موقعها الرسمي، أي أن الدوال فيها لا تغير شيئاً، ولا توجد فيها حلقة for أو متغير يتغير أصلاً، والمسألة كلها سطر واحد يقرأ تقريباً مثل تعريف رياضي: «مجموع السعر × الكمية لكل سطر كميته أكبر من صفر». وهذا هو الملف order.hs:

order :: [(String, Int, Int)]
order = [("coffee", 12, 2), ("cake", 18, 1), ("juice", 9, 0), ("water", 3, 4)]

main :: IO ()
main = print (sum [p * q | (_, p, q) <- order, q > 0])
docker run --rm --name lab-lang-hs -v "$PWD":/w -w /w haskell:9-slim runghc order.hs

والمخرج سوف يكون كما يلي:

54

والصورة التالية تضع الحل الأمري والحل الوظيفي جنباً إلى جنب، لاحظ أن الأول يغير خانة واحدة في الذاكرة أربع مرات، والثاني ينتج قائمة جديدة في كل مرحلة:

مقارنة بمسارين: في الأعلى حلقة Go بخمس خطوات مرقمة تغير المتغير total من 0 إلى 24 ثم 42 ثم تتخطى العصير ثم 54، وفي الأسفل أنبوب JavaScript تمر فيه أسطر الطلب الأربعة على filter فتبقى ثلاثة ثم map فتصبح 24 و18 و12 ثم reduce فتصبح 54 والطلب الأصلي لم يتغير
نفس الإجمالي 54: حلقة تغير متغيراً، أو أنبوب ينتج قيماً جديدة

وقد يتساءل البعض: هل الأسلوب الوظيفي أبطأ لأنه ينشئ قوائم جديدة؟ والإجابة أنه قد ينشئ قوائم وسيطة فعلاً كما في JavaScript أعلاه، وهذا لا يهم في طلب من أربعة أسطر، ولكنه يهم في ملايين العناصر، ولهذا تستخدم لغات مثل Python المولدات Generators التي تمرر العنصر تلو الآخر دون قائمة وسيطة، وتستخدم Haskell التقييم الكسول Lazy Evaluation. والقاعدة العملية أن تكتب الكود الأوضح أولاً، ثم تقيس قبل أن تغيره من أجل السرعة.

البرمجة التصريحية Declarative: صف النتيجة واترك الطريقة

في الأنماط السابقة كتبت بنفسك كيف يحسب الإجمالي، حتى في الأسلوب الوظيفي اخترت ترتيب filter ثم map ثم reduce. أما في البرمجة التصريحية Declarative Programming فأنت تصف ما تريده فقط، والنظام يختار الطريقة. وأشهر مثال تستخدمه كل يوم هو SQL، وهذا الملف order.sql:

CREATE TABLE order_lines (name text, price int, qty int);
INSERT INTO order_lines VALUES ('coffee', 12, 2), ('cake', 18, 1), ('juice', 9, 0), ('water', 3, 4);

SELECT sum(price * qty) AS order_total
FROM order_lines
WHERE qty > 0;

EXPLAIN SELECT sum(price * qty) FROM order_lines WHERE qty > 0;

ونشغله على PostgreSQL في Container مؤقت، ثم نحذفه:

docker run -d --name lab-lang-pg -e POSTGRES_HOST_AUTH_METHOD=trust -v "$PWD":/w postgres:17.11-alpine
docker exec lab-lang-pg pg_isready -U postgres
docker exec lab-lang-pg psql -U postgres -f /w/order.sql
docker rm -f lab-lang-pg

والمخرج سوف يكون كما يلي:

CREATE TABLE
INSERT 0 4
 order_total
-------------
          54
(1 row)

                             QUERY PLAN
--------------------------------------------------------------------
 Aggregate  (cost=27.00..27.01 rows=1 width=8)
   ->  Seq Scan on order_lines  (cost=0.00..25.00 rows=400 width=8)
         Filter: (qty > 0)
(3 rows)

في الإعداد أعلاه لاحظ التالي:

  • الاستعلام لا يحتوي على حلقة ولا على متغير، وإنما يقول: «أعطني مجموع السعر × الكمية من الأسطر التي كميتها أكبر من صفر».
  • الأمر EXPLAIN يكشف الخطة Query Plan التي اختارها مخطط الاستعلامات Planner في PostgreSQL: قراءة الجدول كله Seq Scan مع فلتر، ثم تجميع Aggregate. ولو كان على العمود فهرس Index وكان الجدول كبيراً فقد يختار المخطط طريقة أخرى دون أن تغير حرفاً في استعلامك، وهذا هو جوهر التصريحية كما يشرح توثيق PostgreSQL.

ونفس الفكرة في ملفات البنية التحتية، فتوثيق Terraform يقول صراحة إن لغة Terraform تصريحية، تصف الهدف المطلوب وليس الخطوات للوصول إليه، وترتيب الكتل في الملفات غير مهم. وهذا الملف main.tf يحسب نفس الإجمالي بلغة HCL دون أي مزود Provider، فقط لنرى الأسلوب:

locals {
  order = [
    { name = "coffee", price = 12, qty = 2 },
    { name = "cake",   price = 18, qty = 1 },
    { name = "juice",  price = 9,  qty = 0 },
    { name = "water",  price = 3,  qty = 4 },
  ]
  total = sum([for l in local.order : l.price * l.qty if l.qty > 0])
}

output "order_total" {
  value = local.total
}
docker run --rm --name lab-lang-tf -v "$PWD":/w -w /w hashicorp/terraform:latest init
docker run --rm --name lab-lang-tf -v "$PWD":/w -w /w hashicorp/terraform:latest plan

والمخرج من الأمر plan سوف يكون كما يلي:

Changes to Outputs:
  + order_total = 54

You can apply this plan to save these new output values to the Terraform
state, without changing any real infrastructure.

ولاحظ أن local.total يستخدم local.order المعرف قبله، ولو نقلت تعريف total إلى أعلى الملف فلن يتغير شيء، لأن Terraform يبني ترتيب التنفيذ من العلاقات بين القيم وليس من ترتيب الأسطر. وفي الحياة الحقيقية تكتب بنفس الطريقة «أريد سيرفراً بهذه المواصفات وسجل DNS يشير إليه»، وTerraform يقارن الوصف بالواقع ويقرر ما الذي يجب إنشاؤه أو تعديله.

أما HTML وCSS فهما تصريحيتان بنفس المعنى: تكتب <button> ولا تشرح للمتصفح كيف يرسم الزر بكسلاً بكسلاً، وتكتب display: grid ولا تحسب مواقع العناصر بنفسك، والمتصفح يقرر الطريقة.

البرمجة المنطقية Logic Programming: حقائق وقواعد والسؤال عليك

البرمجة المنطقية Logic Programming تأخذ التصريحية إلى أبعد نقطة: تكتب حقائق Facts وقواعد Rules، ثم تسأل سؤالاً، ومحرك اللغة يبحث بنفسه عن الإجابات التي تحقق القواعد. وأشهر لغاتها Prolog، وهذا الملف order.pl بلغة SWI-Prolog:

line(coffee, 12, 2).
line(cake,   18, 1).
line(juice,   9, 0).
line(water,   3, 4).

line_total(Name, T) :- line(Name, P, Q), Q > 0, T is P * Q.

order_total(Total) :- findall(T, line_total(_, T), Ts), sum_list(Ts, Total).

ونسأل المحرك عن الإجمالي، ثم عن كل سطر له إجمالي:

docker run --rm --name lab-lang-pl -v "$PWD":/w -w /w swipl:latest swipl -q \
  -g "order_total(T), format('order total: ~w~n', [T]), forall(line_total(N, X), format('~w ~w~n', [N, X]))" \
  -t halt order.pl

والمخرج سوف يكون كما يلي:

order total: 54
coffee 24
cake 18
water 12

في الكود أعلاه لاحظ أن القاعدة line_total تقرأ «إجمالي السطر Name هو T إذا كان هناك سطر سعره P وكميته Q، وكانت Q أكبر من صفر، وكانت T تساوي P × Q»، ولم نكتب حلقة تمر على الأسطر، وإنما المحرك هو الذي جرب كل الحقائق ووجد ثلاثة تحقق القاعدة، وتجاهل العصير لأن شرط Q > 0 فشل فيه. وهذا النمط قليل في تطبيقات الويب، ولكن أفكاره حاضرة في أنظمة القواعد ومحركات السياسات، وفي لغة Datalog التي تستخدمها بعض أدوات تحليل الكود.

البرمجة القائمة على الأحداث Event-Driven: الكود ينتظر ما يحدث

هناك أسلوب آخر لا يدخل في الشجرة السابقة لأنه يعمل فوق أي نمط، وهو البرمجة القائمة على الأحداث Event-Driven Programming. فبدلاً من أن يحدد البرنامج ترتيب التنفيذ من البداية، يسجل دوالاً تستجيب لأحداث Events: ضغطة زر في الواجهة، أو طلب HTTP وصل إلى السيرفر، أو منتج أضيف إلى السلة. ويقول توثيق Node.js إن أغلب واجهات Node.js مبنية على هذا الأسلوب. وهذا المثال في الملف order.js داخل مجلد آخر:

const { EventEmitter } = require("node:events");

const shop = new EventEmitter();
let total = 0;

shop.on("item-added", ({ name, price, qty }) => {
  total += price * qty;
  console.log(`event item-added: ${name} -> total ${total}`);
});
shop.on("checkout", () => console.log("event checkout: pay", total));

shop.emit("item-added", { name: "coffee", price: 12, qty: 2 });
shop.emit("item-added", { name: "cake", price: 18, qty: 1 });
shop.emit("item-added", { name: "water", price: 3, qty: 4 });
shop.emit("checkout");
docker run --rm --name lab-lang-ev -v "$PWD":/w -w /w node:22-alpine node order.js

والمخرج سوف يكون كما يلي:

event item-added: coffee -> total 24
event item-added: cake -> total 42
event item-added: water -> total 54
event checkout: pay 54

ولاحظ أن الكود الذي يحسب الإجمالي لا يعرف متى سوف يضاف المنتج القادم، وإنما يستجيب عندما يحدث الحدث. وهذا هو الأساس الذي تقوم عليه حلقة الأحداث Event Loop في Node.js والمتصفح، وقد شرحنا حدودها في مقال المعالج والتزامن. والبرمجة التفاعلية Reactive Programming تبني على نفس الفكرة، ولكنها تتعامل مع الأحداث كتدفق Stream من القيم، وتطبق عليه map وfilter كما في البرمجة الوظيفية، ومن مكتباتها المعروفة ReactiveX مثل RxJS وRxJava.

أغلب اللغات الحديثة متعددة الأنماط Multi-Paradigm

بعد كل هذا قد يبدو أن كل لغة تتبع نمطاً واحداً، ولكن الواقع أن أغلب اللغات التي تستخدمها اليوم متعددة الأنماط Multi-Paradigm. فتوثيق Python نفسه في دليل البرمجة الوظيفية يقول إن Python وC++ وLisp لغات متعددة الأنماط، يمكنك أن تكتب بها برامج إجرائية أو كائنية أو وظيفية. وقد رأيت ذلك فعلاً في هذا المقال: Python كتبنا بها حلاً وظيفياً، وJavaScript كتبنا بها حلاً وظيفياً وحلاً قائماً على الأحداث، وJava منذ الإصدار 8 تدعم الدوال Lambdas وStreams. لذلك فالسؤال العملي ليس «ما هو نمط هذه اللغة؟» وإنما «ما هو النمط الأنسب لهذا الجزء من الكود؟»، فحساب إجمالي من قائمة يناسبه الأسلوب الوظيفي، ونموذج طلب له قواعد وحالات يناسبه الكلاس، والاستعلام عن البيانات مكانه SQL.

مترجمة Compiled أم مفسرة Interpreted؟

قبل الدخول في الأنواع بقي سؤال يخلط بينه وبين الأنواع كثير من المبتدئين، وهو هل اللغة مترجمة Compiled أم مفسرة Interpreted. وقد شرحنا ذلك بالتفصيل في مقال ماذا يحدث عندما تكتب برنامج Hello World وتشغله: الترجمة المسبقة Ahead-of-Time تحول الكود إلى تعليمات آلة قبل التشغيل كما في C وGo وRust، والمفسر يقرأ تعليمات وسيطة Bytecode وينفذها كما في CPython، والترجمة أثناء التشغيل JIT تترجم الأجزاء المتكررة وهي تعمل كما في Java وV8. والذي يهمنا هنا أن طريقة التنفيذ وصفة لتطبيق Implementation معين وليست للغة نفسها، فلغة Python لها مفسر CPython ولها PyPy الذي يستخدم JIT، وأن طريقة التنفيذ لا تحدد نظام الأنواع، فاللغة المترجمة قد تفحص الأنواع قبل التشغيل وقد لا تفحصها، كما سوف ترى في الأقسام التالية.

أنظمة الأنواع Type Systems: متى تكتشف اللغة أخطاءك؟

النوع Type هو الذي يحدد ما هي القيم المسموحة لمتغير، وما هي العمليات التي يمكن إجراؤها عليها: فالرقم يجمع ويضرب، والنص يقطع ويدمج، وجمع رقم مع نص سؤال يجيب عنه نظام الأنواع Type System في كل لغة بطريقته. وهناك سؤالان منفصلان يخلط بينهما كثيرون: متى تفحص اللغة الأنواع، وكم تتسامح عندما يختلف النوعان.

الأنواع الثابتة Static والديناميكية Dynamic: قبل التشغيل أم أثناءه؟

في الأنواع الثابتة Static Typing يعرف المترجم نوع كل متغير وكل تعبير قبل التشغيل، ويرفض البرنامج إذا وجد عملية لا تناسب الأنواع، وتوثيق Java يقول ذلك صراحة في مواصفات اللغة: كل متغير وكل تعبير له نوع معروف وقت الترجمة Compile Time. أما في الأنواع الديناميكية Dynamic Typing فالنوع يرتبط بالقيمة نفسها أثناء التشغيل Runtime، والمتغير يمكن أن يحمل رقماً ثم نصاً، والفحص يحدث فقط عندما ينفذ السطر. ولنرى الفرق بخطأ واحد في الحالتين، وهذا الملف report.py:

def total(order):
    return sum(price * qty for price, qty in order)

def report(order, show_tax):
    t = total(order)
    if show_tax:
        return "total with tax: " + t * 1.15
    return f"total: {t}"

order = [(12, 2), (18, 1), (3, 4)]
print(report(order, show_tax=False))
print(report(order, show_tax=True))
docker run --rm --name lab-lang-py -v "$PWD":/w -w /w python:3.13-slim python report.py

والمخرج سوف يكون كما يلي:

total: 54
Traceback (most recent call last):
  File "/w/report.py", line 12, in <module>
    print(report(order, show_tax=True))
          ~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "/w/report.py", line 7, in report
    return "total with tax: " + t * 1.15
           ~~~~~~~~~~~~~~~~~~~^~~~~~~~~~
TypeError: can only concatenate str (not "float") to str

لاحظ أن البرنامج عمل وطبع السطر الأول بنجاح، والخطأ في السطر 7 كان موجوداً من البداية، ولكن Python لم تكتشفه إلا عندما نفذ الفرع show_tax=True. ولو كان هذا الفرع لا ينفذ إلا في نهاية الشهر عند إصدار الفواتير، فسوف يمر البرنامج من كل اختباراتك اليدوية ويصل إلى السيرفر، ويكتشف المستخدم الخطأ قبلك. والآن نفس البرنامج بلغة Go في الملف main.go:

package main

import "fmt"

func total(order [][2]int) int {
	sum := 0
	for _, l := range order {
		sum += l[0] * l[1]
	}
	return sum
}

func report(order [][2]int, showTax bool) string {
	t := total(order)
	if showTax {
		return "total with tax: " + float64(t)*1.15
	}
	return fmt.Sprintf("total: %d", t)
}

func main() {
	order := [][2]int{{12, 2}, {18, 1}, {3, 4}}
	fmt.Println(report(order, false))
}
docker run --rm --name lab-lang-go -v "$PWD":/w -w /w golang:1.27.1-alpine3.24 go run main.go

والمخرج سوف يكون كما يلي:

# command-line-arguments
./main.go:16:10: invalid operation: "total with tax: " + float64(t) * 1.15 (mismatched types untyped string and float64)

لاحظ أن الدالة main لا تستدعي الفرع الخاطئ أصلاً، فهي تمرر false، ومع ذلك رفض المترجم البرنامج كله ولم يطبع حتى السطر الأول، لأنه يفحص كل الكود وليس فقط الأسطر التي سوف تنفذ. والصورة التالية تبين أين يمسك كل نظام هذا الخطأ:

خط زمني بأربع محطات مرقمة: الكتابة ثم الترجمة والفحص ثم التشغيل والاختبار ثم المستخدم، وفي مسار Go يتوقف الخطأ عند المحطة الثانية برسالة invalid operation، وفي مسار Python يمر الخطأ من الترجمة ويعمل البرنامج حتى ينفذ الفرع الخاطئ فيظهر TypeError في الاختبار أو عند المستخدم، وتحتها شريط يبين أن تكلفة الإصلاح تزيد كلما تأخر اكتشاف الخطأ
الخطأ نفسه: المترجم يمسكه عند المحطة الثانية، والمفسر عند تنفيذ السطر

وقد يتساءل البعض: إذا كانت الأنواع الثابتة تمسك الأخطاء مبكراً فلماذا لا تستخدمها كل اللغات؟ والإجابة أن لها ثمناً: كود أكثر لتعريف الأنواع، وخطوة ترجمة تنتظرها، وبعض الأشياء التي تكتب في سطر واحد في Python مثل قراءة JSON بشكل غير معروف مسبقاً تحتاج في Java عملاً أكثر. والأنواع الديناميكية تعطيك سرعة في البداية ومرونة في السكربتات الصغيرة، ولكن كلما كبر الكود وزاد عدد المطورين زادت قيمة المترجم الذي يمسك الأخطاء عنك، ولهذا السبب ظهرت الأنواع التدريجية التي سوف نراها بعد قليل. ولاحظ أن الاختبارات Tests تعوض جزءاً من ذلك في اللغات الديناميكية، فالخطأ أعلاه كان سوف يظهر في اختبار يغطي الفرع show_tax=True، ولكنها لا تفحص إلا الحالات التي كتبت لها اختباراً.

الأنواع القوية Strong والضعيفة Weak: هل تحول اللغة الأنواع بصمت؟

السؤال الثاني مستقل عن الأول: عندما تجمع نصاً مع رقم، هل ترفض اللغة العملية، أم تحول أحدهما إلى نوع الآخر بصمت Implicit Conversion وتكمل؟ اللغة التي ترفض تسمى قوية الأنواع Strongly Typed، والتي تحول بصمت تسمى ضعيفة الأنواع Weakly Typed. وهذا المثال بلغة JavaScript في الملف weak.js:

console.log("1" + 1);
console.log("5" - 2);
console.log("5" * "2");
console.log(1 == "1", 1 === "1");
console.log([] + {});
docker run --rm --name lab-lang-node -v "$PWD":/w -w /w node:22-alpine node weak.js

والمخرج سوف يكون كما يلي:

11
3
10
true false
[object Object]

لاحظ أن "1" + 1 أعطت النص 11 لأن + مع نص تعني الدمج، بينما "5" - 2 أعطت الرقم 3 لأن الطرح لا يعمل إلا على الأرقام فحولت النص إلى رقم، و1 == "1" صحيحة لأن == تحول الأنواع قبل المقارنة، و=== لا تحول فأعطت false. وتوثيق MDN يصف JavaScript بأنها لغة ضعيفة الأنواع تسمح بالتحويل الضمني بدلاً من رمي خطأ، ولهذا السبب ينصح دائماً باستخدام ===. والآن نفس العملية في Python، في الملف strong.py:

print("5" * 2)
print(int("1") + 1)
print("1" + 1)
docker run --rm --name lab-lang-py -v "$PWD":/w -w /w python:3.13-slim python strong.py

والمخرج سوف يكون كما يلي:

55
2
Traceback (most recent call last):
  File "/w/strong.py", line 3, in <module>
    print("1" + 1)
          ~~~~^~~
TypeError: can only concatenate str (not "int") to str

لاحظ أن Python ديناميكية وقوية معاً: لم تفحص الأنواع قبل التشغيل، ولكنها عندما وصلت إلى "1" + 1 رفضت أن تخمن ما تريده ورمت TypeError، والتحويل لا يحدث إلا إذا طلبته صراحة ب int("1"). ولاحظ أيضاً أن "5" * 2 أعطت 55، وهذا ليس تحويلاً للأنواع، وإنما عملية معرفة لتكرار النص، فالقوة لا تعني أن كل عملية بين نوعين ممنوعة.

وقد يتفاجأ البعض بأن لغة ثابتة الأنواع مثل C ضعيفة في نفس الوقت، وهذا الملف weak.c يبين السبب:

#include <stdio.h>

int main(void) {
    char c = 'A';
    int n = c + 1;
    double price = 12.99;
    int cents = price;
    printf("%d %d\n", n, cents);
    if (-1 < sizeof(int))
        printf("-1 is less than 4\n");
    else
        printf("-1 is NOT less than 4\n");
    return 0;
}
docker run --rm --name lab-lang-c -v "$PWD":/w -w /w rust:1-alpine sh -c 'gcc -o /tmp/weak weak.c && /tmp/weak && gcc -Wall -Wextra -o /tmp/weak weak.c'

والمخرج سوف يكون كما يلي:

66 12
-1 is NOT less than 4
$ gcc -Wall -Wextra -o weak weak.c
weak.c: In function 'main':
weak.c:9:12: warning: comparison of integer expressions of different signedness: 'int' and 'long unsigned int' [-Wsign-compare]
    9 |     if (-1 < sizeof(int))
      |            ^

في الكود أعلاه لاحظ التالي:

  • الحرف 'A' أصبح الرقم 66 بعد الجمع لأن الحروف في C أرقام صغيرة، والسعر 12.99 أصبح 12 بصمت عند وضعه في متغير int، دون أي خطأ.
  • والأخطر أن -1 < sizeof(int) كانت خاطئة، فالبرنامج يقول إن -1 ليست أصغر من 4. والسبب أن sizeof تعيد نوعاً بدون إشارة Unsigned، فتحول C الرقم -1 إليه بقواعد التحويلات الحسابية المعتادة Usual Arithmetic Conversions كما في مرجع cppreference، فيصبح أكبر رقم ممكن.
  • الترجمة العادية لم تظهر أي تحذير، والتحذير -Wsign-compare ظهر فقط مع -Wall -Wextra، ولهذا لا تقم بترجمة كود C بدون هذه الخيارات، والسبب أنها الطريقة الوحيدة لتعرف أن المترجم حول شيئاً بصمت، كما يشرح توثيق GCC.

والصورة التالية تضع اللغات على السؤالين معاً:

مربع من أربعة أجزاء: الأنواع الثابتة على اليمين والديناميكية على اليسار، والقوية في الأعلى والضعيفة في الأسفل، وفي الثابتة القوية Haskell وRust وJava وC# وKotlin وSwift وGo، وفي الديناميكية القوية Python وRuby، وفي الثابتة الضعيفة C وC++ وTypeScript مع سهم إلى JavaScript لأنها تعمل كJavaScript، وفي الديناميكية الضعيفة JavaScript وPHP مع سهم strict_types، مع أمثلة من المخرجات الحقيقية
متى تفحص الأنواع شيء، وكم تتسامح مع خلطها شيء آخر

ولاحظ أن «قوية» و«ضعيفة» ليس لهما تعريف رسمي متفق عليه مثل «ثابتة» و«ديناميكية»، وإنما هما طيف Spectrum: فC++ أقوى من C في بعض المواضع، وPHP تصبح أكثر صرامة مع declare(strict_types=1) في معاملات الدوال كما في توثيق PHP، وTypeScript تفحص الأنواع قبل التشغيل ثم يعمل الكود كJavaScript بكل تحويلاتها. لذلك فالمواقع في الصورة تقريبية، والمهم أن تعرف كيف تتصرف لغتك في الحالات التي تخصك.

استنتاج الأنواع Type Inference: ثابتة دون أن تكتب النوع

من أكثر ما ينفر المبتدئين من الأنواع الثابتة أنهم يتخيلون كتابة النوع بجانب كل متغير كما في Java القديمة. ولكن أغلب اللغات الثابتة الحديثة تستنتج النوع Type Inference من القيمة التي تضعها، فتكتب كأنك في لغة ديناميكية، ويبقى الفحص ثابتاً. وهذا مثال بلغة Go في الملف main.go:

package main

import "fmt"

func main() {
	total := 54
	price := 12.5
	name := "coffee"
	fmt.Printf("%T %T %T\n", total, price, name)
	// total = "fifty four"
}
docker run --rm --name lab-lang-go -v "$PWD":/w -w /w golang:1.27.1-alpine3.24 go run main.go

والمخرج سوف يكون كما يلي:

int float64 string

لم نكتب كلمة int أو string في أي مكان، والمعامل := في Go يعرف المتغير ويستنتج نوعه كما في مواصفات Go. والآن نزيل التعليق عن السطر الأخير لنضع نصاً في المتغير total:

# command-line-arguments
./main.go:10:10: cannot use "fifty four" (untyped string constant) as int value in assignment

لاحظ أن النوع استنتج مرة واحدة عند التعريف، ثم أصبح ثابتاً، فالمتغير total هو int إلى الأبد. ونفس الشيء في Rust مع let، وهذا الملف main.rs:

fn main() {
    let prices = vec![12, 18, 3];
    let mut total = 0;
    for p in &prices {
        total += p;
    }
    total = "thirty three";
    println!("{total}");
}
docker run --rm --name lab-lang-rs -v "$PWD":/w -w /w rust:1-alpine rustc main.rs

والمخرج سوف يكون كما يلي:

error[E0308]: mismatched types
 --> main.rs:7:13
  |
3 |     let mut total = 0;
  |                     - expected due to this value
...
7 |     total = "thirty three";
  |             ^^^^^^^^^^^^^^ expected integer, found `&str`

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

لاحظ أن رسالة Rust تشير إلى السطر الذي استنتجت منه النوع expected due to this value، ثم إلى السطر الذي خالفه، وهذا ما يجعل الاستنتاج مريحاً: المترجم يشرح لك من أين جاء بالنوع. وتوثيق Rust يقول في فصل أنواع البيانات إن المترجم يستطيع غالباً استنتاج النوع من القيمة وطريقة استخدامها، ونفس الفكرة في TypeScript كما سوف ترى في القسم التالي.

الأنواع التدريجية Gradual Typing: TypeScript وPython مع mypy

لنفرض أن لديك مشروعاً كبيراً بلغة JavaScript أو Python، وبدأت الأخطاء من نوع «undefined is not a function» تصل إلى المستخدمين، فإعادة كتابة المشروع بلغة ثابتة الأنواع ليست خياراً واقعياً. وهنا جاءت الأنواع التدريجية Gradual Typing، وفكرتها التي تشرحها PEP 483 أن تضيف الأنواع إلى جزء من الكود، ويبقى الباقي ديناميكياً، ثم تزيد التغطية تدريجياً.

ولنبدأ ب TypeScript، وهي JavaScript مع أنواع، والملف order.ts التالي فيه خطآن مقصودان:

interface Line {
  name: string;
  price: number;
  qty: number;
}

function total(order: Line[]): number {
  return order.filter((l) => l.qty > 0).reduce((s, l) => s + l.price * l.qty, 0);
}

let count = 4;            // inferred as number
count = "four";

const order: Line[] = [
  { name: "coffee", price: 12, qty: 2 },
  { name: "cake", price: "18", qty: 1 },
];
console.log(total(order));

ونثبت المترجم tsc داخل Container ثم نفحص الملف ونشغل الناتج:

docker run --rm --name lab-lang-ts -v "$PWD":/w -w /w node:22-alpine sh -c \
  'npm i -g [email protected] >/dev/null 2>&1; tsc --strict --target es2022 order.ts; node order.js'

والمخرج سوف يكون كما يلي:

order.ts(12,1): error TS2322: Type 'string' is not assignable to type 'number'.
order.ts(16,19): error TS2322: Type 'string' is not assignable to type 'number'.
42

في المخرج أعلاه لاحظ التالي:

  • الخطأ الأول في السطر 12 جاء من استنتاج النوع: count بدأ رقماً فأصبح number، والخطأ الثاني في السطر 16 لأن السعر "18" نص والواجهة Line تقول number.
  • ومع وجود الخطأين أنشأ tsc الملف order.js وعمل وطبع 42 بدلاً من 54، فقد ضربت JavaScript النص "18" في 1 فحولته إلى رقم بصمت. والسبب أن الخيار noEmitOnError معطل افتراضياً، ولهذا اجعله true في مشروعك، أو اجعل خط CI يفشل عندما يفشل tsc.

وإذا صححنا الخطأين، فهذا هو ملف JavaScript الذي ينتجه tsc من الملف ok.ts:

"use strict";
function total(order) {
    return order.filter((l) => l.qty > 0).reduce((s, l) => s + l.price * l.qty, 0);
}
const order = [
    { name: "coffee", price: 12, qty: 2 },
    { name: "cake", price: 18, qty: 1 },
];
console.log(total(order));

لاحظ أن الواجهة Line وكل : number اختفت تماماً، وهذا ما يسميه توثيق TypeScript الأنواع الممحوة Erased Types: الأنواع موجودة فقط وقت الفحص، والذي يعمل في المتصفح أو Node.js هو JavaScript عادية لا تعرف شيئاً عنها. لذلك فالبيانات التي تأتي من خارج برنامجك، مثل جسم طلب HTTP أو ملف JSON، لا يحميها TypeScript وحده، وتحتاج إلى تحقق Validation وقت التشغيل.

وفي Python جاءت تلميحات الأنواع Type Hints مع PEP 484، ولكن لاحظ الجملة الأولى في توثيق الوحدة typing: مفسر Python لا يفرض هذه التلميحات، وإنما تستخدمها أدوات خارجية مثل mypy. وهذا الملف app.py:

from dataclasses import dataclass


@dataclass
class Line:
    name: str
    price: int
    qty: int


def total(order: list[Line]) -> int:
    return sum(l.price * l.qty for l in order if l.qty > 0)


def receipt(order: list[Line]) -> str:
    return "total: " + total(order)


def legacy(order):
    return "total: " + total(order)


order = [Line("coffee", 12, 2), Line("cake", "18", 1)]
print(order[1])
print(total(order))

نشغله أولاً بـ Python مباشرة:

docker run --rm --name lab-lang-py -v "$PWD":/w -w /w python:3.13-slim python app.py

والمخرج سوف يكون كما يلي:

Line(name='cake', price='18', qty=1)
Traceback (most recent call last):
  File "/w/app.py", line 25, in <module>
    print(total(order))
          ~~~~~^^^^^^^
  File "/w/app.py", line 12, in total
    return sum(l.price * l.qty for l in order if l.qty > 0)
TypeError: unsupported operand type(s) for +: 'int' and 'str'

لاحظ أن ال Dataclass قبلت السعر '18' كنص رغم أن التلميح يقول int، ولم يظهر الخطأ إلا عند الجمع داخل total. والآن نفحص نفس الملف ب mypy قبل التشغيل:

docker run --rm --name lab-lang-mypy -v "$PWD":/w -w /w python:3.13-slim sh -c \
  'pip install -q mypy==2.4.0 && mypy app.py; mypy --check-untyped-defs app.py'

والمخرج سوف يكون كما يلي:

app.py:16: error: Unsupported operand types for + ("str" and "int")  [operator]
app.py:23: error: Argument 2 to "Line" has incompatible type "str"; expected "int"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)
$ mypy --check-untyped-defs app.py
app.py:16: error: Unsupported operand types for + ("str" and "int")  [operator]
app.py:20: error: Unsupported operand types for + ("str" and "int")  [operator]
app.py:23: error: Argument 2 to "Line" has incompatible type "str"; expected "int"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)

في المخرج أعلاه لاحظ التالي:

  • mypy وجد خطأ السعر في السطر 23 قبل أن يعمل البرنامج، ووجد أيضاً خطأ في السطر 16 داخل الدالة receipt لم يظهر في التشغيل أصلاً لأن أحداً لم يستدعها، وهذه بالضبط فائدة الفحص الثابت.
  • الدالة legacy فيها نفس الخطأ في السطر 20، ولكن mypy تجاهلها في الفحص الأول لأنها بدون تلميحات، فهذا هو الجزء الديناميكي من «التدريجي». ومع الخيار --check-untyped-defs فحص بداخلها أيضاً فظهرت ثلاثة أخطاء. لذلك لا تقم بإضافة mypy لمشروع قديم ثم تفترض أن كل الكود مفحوص، والسبب أن الدوال بدون تلميحات تبقى خارج الفحص حتى تضيف لها التلميحات أو تفعل هذا الخيار.

والصورة التالية تجمع الطريقين:

مسار TypeScript: الملف order.ts يمر على tsc الذي يفحص الأنواع ويظهر الخطأ TS2322، ثم ينتج order.js بدون أنواع يعمل في Node.js أو المتصفح، ومسار Python: الملف app.py يذهب في طريق إلى python3 الذي يتجاهل التلميحات فيظهر TypeError أثناء التشغيل، وفي طريق آخر إلى mypy الذي يظهر الأخطاء قبل التشغيل
الأنواع التدريجية فحص قبل التشغيل، والكود الذي يعمل لا يعرف عنها شيئاً

الأنواع الاسمية Nominal والبنيوية Structural: هل يهم اسم النوع أم شكله؟

عندما تكتب دالة تستقبل «أي شيء له دالة total()»، يبقى سؤال: كيف تقرر اللغة أن الكائن الذي مررته مقبول؟ في النظام الاسمي Nominal Typing يجب أن يعلن الكلاس صراحة أنه يطبق الواجهة، وفي النظام البنيوي Structural Typing يكفي أن يملك الكائن الدوال المطلوبة بنفس الأشكال. وهذا الملف Main.java، فيه كلاس Invoice له دالة total() ولكنه لا يذكر الواجهة:

interface HasTotal {
    int total();
}

class Invoice {
    public int total() { return 54; }
}

public class Main {
    static void printTotal(HasTotal x) {
        System.out.println("total: " + x.total());
    }

    public static void main(String[] args) {
        printTotal(new Invoice());
    }
}
docker run --rm --name lab-lang-java -v "$PWD":/w -w /w eclipse-temurin:25-jdk-alpine java Main.java

والمخرج سوف يكون كما يلي:

Main.java:15: error: method printTotal in class Main cannot be applied to given types;
        printTotal(new Invoice());
        ^
  required: HasTotal
  found:    Invoice
  reason: argument mismatch; Invoice cannot be converted to HasTotal
1 error
error: compilation failed

رفضت Java الكلاس رغم أن شكله مطابق تماماً، والحل الوحيد أن تكتب class Invoice implements HasTotal. والآن نفس الشيء بلغة Go في الملف main.go:

package main

import "fmt"

type HasTotal interface {
	Total() int
}

type Invoice struct{}

func (Invoice) Total() int { return 54 }

func printTotal(x HasTotal) {
	fmt.Println("total:", x.Total())
}

func main() {
	printTotal(Invoice{})
}
docker run --rm --name lab-lang-go -v "$PWD":/w -w /w golang:1.27.1-alpine3.24 go run main.go

والمخرج سوف يكون كما يلي:

total: 54

قبلت Go النوع Invoice دون أن يذكر اسم HasTotal في أي مكان، لأن الأسئلة الشائعة للغة Go تقول إن النوع يطبق الواجهة بمجرد أن يطبق دوالها، ولا توجد كلمة implements في اللغة أصلاً. وTypeScript بنيوية أيضاً كما يقول توثيق التوافق بين الأنواع، وهذا الملف structural.ts:

interface HasTotal {
  total(): number;
}

class Invoice {
  total() { return 54; }
}

function printTotal(x: HasTotal) {
  console.log("total:", x.total());
}

printTotal(new Invoice());
printTotal({ total: () => 42 });
printTotal({ sum: () => 42 });
docker run --rm --name lab-lang-ts -v "$PWD":/w -w /w node:22-alpine sh -c \
  'npm i -g [email protected] >/dev/null 2>&1; tsc --strict --target es2022 structural.ts; node structural.js'

والمخرج سوف يكون كما يلي:

structural.ts(15,14): error TS2353: Object literal may only specify known properties, and 'sum' does not exist in type 'HasTotal'.
total: 54
total: 42
/w/structural.js:6
    console.log("total:", x.total());
                            ^

TypeError: x.total is not a function

لاحظ أن الكلاس Invoice والكائن الحرفي { total: () => 42 } قبلا لأن شكلهما مطابق، أما الكائن الذي فيه sum بدلاً من total فرفضه tsc، ولأن الملف أنشئ رغم الخطأ كما رأينا من قبل فقد وصل الخطأ نفسه إلى وقت التشغيل. والصورة التالية تلخص الفرق:

لوحتان: في Nominal بلغة Java تقبل الدالة printTotal الكلاس Invoice فقط إذا كتب implements HasTotal، والكلاس بدونها يرفض بخطأ Invoice cannot be converted to HasTotal، وفي Structural بلغة Go وTypeScript تقبل الدالة الكلاس Invoice والكائن الذي فيه total لأن شكلهما مطابق، وترفض الكائن الذي فيه sum بخطأ TS2353
النظام الاسمي يسأل: هل أعلنت أنك HasTotal؟ والبنيوي يسأل: هل عندك total()؟

وقد يتساءل البعض: أيهما أفضل؟ والإجابة أن الاسمي يجعل العلاقة موثقة في الكود، فلا يطابق نوع واجهة بالصدفة لأن له دالة بنفس الاسم ومعنى مختلف، أما البنيوي فيسمح لك بتعريف واجهة لنوع من مكتبة لا تملك تعديلها، وهذا ما تذكره الأسئلة الشائعة للغة Go كسبب لاختيارها.

الحماية من Null أو Null Safety: الخطأ الذي سماه صاحبه خطأ المليار دولار

أشهر خطأ في تاريخ البرمجة هو أن تستخدم متغيراً قيمته null أو undefined وأنت تظن أن فيه قيمة. وهذا مثال بلغة Java في الملف Main.java، يبحث عن كوبون خصم غير موجود:

import java.util.Map;

public class Main {
    static Map<String, String> coupons = Map.of("WELCOME", "10%");

    static String findCoupon(String code) {
        return coupons.get(code);
    }

    public static void main(String[] args) {
        String coupon = findCoupon("SUMMER");
        System.out.println(coupon.length());
    }
}
docker run --rm --name lab-lang-java -v "$PWD":/w -w /w eclipse-temurin:25-jdk-alpine \
  sh -c 'javac -g Main.java && java Main'

والمخرج سوف يكون كما يلي:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "coupon" is null
	at Main.main(Main.java:12)

ترجم البرنامج بدون أي تحذير لأن النوع String في Java يقبل null، وانفجر عند التشغيل. ولاحظ أن الرسالة تذكر اسم المتغير coupon، وهذه الرسائل المفصلة جاءت مع JEP 358، ولكن اسم المتغير يظهر فقط إذا ترجمت مع الخيار -g، وبدونه سوف تقرأ "<local1>" is null كما ظهر لنا عند التشغيل المباشر ب java Main.java.

📌
في مؤتمر QCon London سنة 2009 قدم توني هور Tony Hoare محاضرة بعنوان Null References: The Billion Dollar Mistake، قال فيها إنه أضاف مرجع Null إلى لغة ALGOL W سنة 1965 لأنه كان سهل التنفيذ، وسماه «خطأ المليار دولار». ومن هنا يأتي الاسم الذي تراه اليوم في توثيق Kotlin عن Null Safety.

والحل في اللغات الحديثة أن يصبح «قد لا توجد قيمة» جزءاً من النوع نفسه، فيمنعك المترجم من استخدام القيمة قبل أن تتعامل مع حالة غيابها. ففي TypeScript مع الخيار strictNullChecks، وهذا الملف nulls.ts:

const coupons: Record<string, number> = { WELCOME: 10 };

function findCoupon(code: string): number | undefined {
  return coupons[code];
}

const pct = findCoupon("SUMMER");
console.log(pct.toFixed(1));
docker run --rm --name lab-lang-ts -v "$PWD":/w -w /w node:22-alpine sh -c \
  'npm i -g [email protected] >/dev/null 2>&1; tsc --strict --target es2022 nulls.ts;
   tsc --strict --strictNullChecks false --target es2022 nulls.ts && node nulls.js'

والمخرج سوف يكون كما يلي:

nulls.ts(8,13): error TS18048: 'pct' is possibly 'undefined'.
/w/nulls.js:7
console.log(pct.toFixed(1));
                ^

TypeError: Cannot read properties of undefined (reading 'toFixed')

مع --strict رفض tsc الكود لأن النوع number | undefined يقول صراحة إن القيمة قد لا توجد، وعندما عطلنا strictNullChecks مر الفحص بنجاح وانفجر الكود عند التشغيل كما في Java تماماً. لذلك لا تقم بتعطيل strict في مشروع TypeScript جديد، والسبب أنك تعطل بذلك أهم فحص فيه.

أما Rust فلا يوجد فيها null أصلاً، والقيمة التي قد لا توجد نوعها Option، وهو إما Some(value) أو None. وهذا الملف main.rs يحاول استخدام النتيجة مباشرة:

use std::collections::HashMap;

fn find_coupon(coupons: &HashMap<&str, u32>, code: &str) -> Option<u32> {
    coupons.get(code).copied()
}

fn main() {
    let coupons = HashMap::from([("WELCOME", 10)]);
    let pct: u32 = find_coupon(&coupons, "SUMMER");
    println!("{pct}");
}
docker run --rm --name lab-lang-rs -v "$PWD":/w -w /w rust:1-alpine rustc main.rs

والمخرج سوف يكون كما يلي:

error[E0308]: mismatched types
 --> main.rs:9:20
  |
9 |     let pct: u32 = find_coupon(&coupons, "SUMMER");
  |              ---   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `u32`, found `Option<u32>`
  |              |
  |              expected due to this
  |
  = note: expected type `u32`
             found enum `Option<u32>`
help: consider using `Option::expect` to unwrap the `Option<u32>` value, panicking if the value is an `Option::None`
  |
9 |     let pct: u32 = find_coupon(&coupons, "SUMMER").expect("REASON");
  |                                                   +++++++++++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

رفض المترجم أن تعامل Option<u32> كأنها u32، واقترح expect التي تنهي البرنامج إذا لم توجد قيمة، ولكن الطريقة الصحيحة أن تتعامل مع الحالتين ب match، كما في الملف ok.rs:

use std::collections::HashMap;

fn find_coupon(coupons: &HashMap<&str, u32>, code: &str) -> Option<u32> {
    coupons.get(code).copied()
}

fn main() {
    let coupons = HashMap::from([("WELCOME", 10)]);
    for code in ["WELCOME", "SUMMER"] {
        match find_coupon(&coupons, code) {
            Some(pct) => println!("{code}: {pct}% off"),
            None => println!("{code}: no such coupon"),
        }
    }
}
docker run --rm --name lab-lang-rs -v "$PWD":/w -w /w rust:1-alpine sh -c 'rustc ok.rs -o /tmp/ok && /tmp/ok'

والمخرج سوف يكون كما يلي:

WELCOME: 10% off
SUMMER: no such coupon

ونفس الفكرة في Kotlin، فالنوع String لا يقبل null، والنوع String? يقبله ويجبرك المترجم على فحصه أو استخدام ?. قبل الوصول إليه، كما في توثيق Kotlin، وفي Swift بنفس الطريقة مع ال Optionals.

الأنواع العامة Generics: دالة واحدة لأكثر من نوع

آخر مفهوم في هذا القسم، لنفرض أنك تريد دالة Sum تجمع قائمة أرقام صحيحة، وأخرى تجمع أرقاماً عشرية، فبدون الأنواع العامة Generics سوف تكتب الدالة مرتين، أو تكتب دالة تقبل أي شيء فتخسر الفحص الثابت. والأنواع العامة تسمح لك بكتابة الدالة مرة واحدة مع معامل نوع Type Parameter، كما في هذا الملف بلغة Go:

package main

import "fmt"

func Sum[T int | float64](xs []T) T {
	var s T
	for _, x := range xs {
		s += x
	}
	return s
}

func main() {
	fmt.Println(Sum([]int{24, 18, 12}))
	fmt.Println(Sum([]float64{12.5, 18.25}))
	fmt.Println(Sum([]string{"a", "b"}))
}

بدون السطر الأخير يعمل البرنامج ويكون المخرج كما يلي:

54
30.75

ومع السطر الأخير الذي يمرر قائمة نصوص يرفض المترجم البرنامج:

# command-line-arguments
./main.go:16:17: string does not satisfy int | float64 (string missing in int | float64)

لاحظ أن القيد int | float64 يحدد الأنواع المسموحة، فالدالة عامة ولكنها ليست مفتوحة لأي شيء، والمترجم يفحص كل استدعاء بنوعه. والأنواع العامة موجودة في Java وC# وRust وTypeScript وغيرها، ويشرحها دليل Go وتوثيق TypeScript.

مقارنة أشهر اللغات

الجدول التالي يجمع ما سبق لأشهر اللغات، وكل خلية فيه من التوثيق الرسمي للغة، وعمود «قوية أو ضعيفة» تصنيف تقريبي حسب السلوك كما شرحنا، وطريقة التنفيذ هي للتطبيق الرسمي أو الأشهر لكل لغة:

اللغةالأنماط Paradigmsالأنواع Typesالتنفيذ Executionإدارة الذاكرة Memory Managementالاستخدام الشائع
Cإجرائيةثابتة، ضعيفةترجمة مسبقة AOTيدوية malloc وfreeأنظمة التشغيل والأنظمة المدمجة Embedded
C++متعددة: إجرائية وكائنية وGenericثابتة، ضعيفة نسبياً بسبب تحويلات CAOTيدوية مع RAIIالألعاب والمتصفحات وقواعد البيانات
Javaكائنية مع عناصر وظيفيةثابتة، قوية كما في JLSBytecode على JVM مع JITGCأنظمة المؤسسات وتطبيقات Android
C#كائنية مع عناصر وظيفيةثابتة، قويةCIL على CLR مع JITGCتطبيقات .NET والمؤسسات وألعاب Unity
Kotlinكائنية ووظيفيةثابتة، قوية، مع Null SafetyBytecode على JVM، وأيضاً JavaScript وNativeGC على JVMتطبيقات Android والخوادم
Goإجرائية مع واجهات وبدون وراثةثابتة، قوية، واجهات بنيويةAOTGCالخوادم وأدوات السحابة وسطر الأوامر
Rustمتعددة: إجرائية ووظيفية مع Traitsثابتة، قوية، Option بدلاً من nullAOTالملكية Ownership بدون GCبرمجة الأنظمة وأدوات سطر الأوامر وWebAssembly
Swiftمتعددة: كائنية وProtocols ووظيفيةثابتة، قوية، OptionalsAOTARC عد المراجعتطبيقات أجهزة Apple
Pythonمتعددةديناميكية، قوية، مع تلميحات اختياريةBytecode مع مفسر CPythonعد المراجع مع جامع للدوراتتحليل البيانات والأتمتة والويب
JavaScriptمتعددة: Prototypes ووظيفية وأحداثديناميكية، ضعيفةمحركات JIT مثل V8GCالويب في المتصفح والخوادم مع Node.js
TypeScriptمثل JavaScriptثابتة تدريجية بنيوية، تمحى عند الترجمةتترجم إلى JavaScriptمثل JavaScriptتطبيقات الويب الكبيرة
PHPإجرائية وكائنيةديناميكية، ضعيفة افتراضياً مع strict_types اختياريBytecode مع OPcache، وJIT معطل افتراضياً منذ PHP 8.4عد المراجع مع جامع للدوراتمواقع وتطبيقات الويب
Rubyكائنية وكل شيء فيها كائن، مع عناصر وظيفيةديناميكية، قويةBytecode مع YJIT اختياريGCتطبيقات الويب مع Rails
Haskellوظيفية نقيةثابتة، قوية، استنتاج للأنواعAOT عبر GHCGCالمترجمات والبحث والتعليم
SQLتصريحيةأعمدة لها أنواع يفحصها محرك قاعدة البياناتالمخطط يختار خطة التنفيذيديرها محرك قاعدة البياناتالاستعلام عن البيانات

ولاحظ في الجدول أن عمود إدارة الذاكرة له ثلاث طرق رئيسية: يدوية كما في C حيث تحجز الذاكرة وتحررها بنفسك فتأتي معها أخطاء مثل استخدام ذاكرة بعد تحريرها، وجامع النفايات Garbage Collector كما في Java وGo وPython حيث يبحث برنامج داخل التطبيق عن الذاكرة التي لم يعد يستخدمها أحد، والملكية Ownership في Rust حيث يفحص المترجم قبل التشغيل من يملك كل قيمة ومتى تحرر، فتحصل على الأمان بدون جامع نفايات، وثمنه قواعد يتعلمها المبتدئ ببطء.

ماذا يعني هذا عند اختيار لغة لمشروعك؟

بعد هذه الجولة لا توجد لغة «أفضل» بشكل مطلق، وإنما لغة أنسب لمشكلة وفريق معين. وهذه أسئلة عملية تساعدك:

  • حجم الكود وعمره وعدد المطورين: سكربت لمرة واحدة يناسبه Python أو Bash، أما مشروع يعيش سنوات ويعمل عليه عدة مطورين فقيمة الأنواع الثابتة Static أو التدريجية Gradual فيه كبيرة، لأن المترجم يمسك ما ينساه المطورون عند تعديل كود لم يكتبوه.
  • البيئة التي يعمل فيها: المتصفح يعني JavaScript أو TypeScript، وقاعدة البيانات تعني SQL، وتطبيق iPhone يعني Swift غالباً.
  • كلفة الخطأ: برنامج يتعامل مع المال أو الذاكرة مباشرة يستحق لغة ترفض أكثر قبل التشغيل، والسبب أن الخطأ الذي يصل إلى المستخدم هناك مكلف جداً.
  • المشروع القائم: إذا كان مشروعك بلغة ديناميكية فلا تقم بإعادة كتابته، وإنما أضف TypeScript أو mypy تدريجياً واجعل الفحص جزءاً من خط CI، وابدأ بالملفات التي تتغير كثيراً.

وتذكر أن تعلم لغة من نمط مختلف عن لغتك الأساسية يغير طريقة كتابتك حتى في لغتك، فالمطور الذي جرب Haskell أو SQL جيداً يكتب حلقات أقل ودوالاً نقية أكثر في Java وPython.

تدريب عملي

  1. أضف إلى مثال Java كلاساً ثالثاً للخصم اسمه FixedOff يطرح مبلغاً ثابتاً، ثم شغل البرنامج ولاحظ أن الكلاس Order لم يتغير فيه أي سطر.
  2. غير في الملف app.py الدالة legacy بإضافة تلميحات للمعامل وقيمة الإرجاع، ثم شغل mypy app.py بدون --check-untyped-defs ولاحظ ظهور الخطأ.
  3. أضف "noEmitOnError": true إلى ملف tsconfig.json في مشروع TypeScript، ثم تأكد أن order.js لا ينشأ عند وجود الخطأ.

وبعد التدريب تأكد أنه لم يبق أي Container من المختبر:

docker ps -a --filter name=lab-lang

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • توجد لغات كثيرة لأن كل لغة تختار نقطة مختلفة بين السرعة وسهولة الكتابة والأمان والبيئة التي تعمل فيها.
  • الأمرية Imperative تصف الخطوات وتغير الحالة، ومنها الإجرائية Procedural والكائنية OOP، والتصريحية Declarative تصف النتيجة، ومنها الوظيفية Functional وSQL وTerraform والمنطقية Logic، وأغلب اللغات الحديثة تجمع أكثر من نمط.
  • الأنواع الثابتة Static تمسك الخطأ قبل التشغيل حتى في الأسطر التي لا تنفذ، والديناميكية Dynamic تمسكه فقط عندما ينفذ السطر، وقد يكون ذلك عند المستخدم.
  • القوة Strong والضعف Weak سؤال آخر: هل تحول اللغة الأنواع بصمت؟ وPython ديناميكية قوية Dynamic Strong، وC ثابتة ضعيفة Static Weak، وJavaScript ديناميكية ضعيفة Dynamic Weak.
  • استنتاج الأنواع Type Inference يعطيك الفحص الثابت دون كتابة الأنواع في كل سطر، والأنواع التدريجية Gradual Typing مع TypeScript وmypy تضيف الفحص إلى مشروع قائم، والأنواع فيها تمحى أو تتجاهل وقت التشغيل.
  • Java تقبل النوع باسمه المعلن Nominal، وGo وTypeScript تقبلانه بشكله Structural، وKotlin وRust وTypeScript مع strict تجبرك على التعامل مع غياب القيمة قبل استخدامها.
دليلماذا يحدث عندما تشغل برنامج Hello World؟نتتبع برنامج C صغيراً من المحرر إلى الشاشة، فترى كل طبقة يمر بها في الجهاز حتى تظهر الجملة أمامك.دليلاللغة شيء والمترجم شيء آخر: من يقرر مستقبل لغات البرمجة؟لماذا لا تصح جمل مثل «Python بطيئة»، وما الفرق بين مواصفة اللغة والبرامج التي تطبقها، ومن يقرر مستقبلها.دليلقصص لغات البرمجة ومن صنعها، من Fortran إلى Rustمن صنع اللغات التي نكتب بها كل يوم، وما المشكلة التي دفعته إلى ذلك، وكيف تشرح قصة كل لغة قراراتها.دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.دليلإدارة النسخ وGit للمبتدئين: لماذا نحتاجها وكيف تعمللماذا تضيع الملفات بأسماء final_v2 ويمسح الزملاء عمل بعضهم، وكيف يحل Git ذلك على مشروع حقيقي من أول commit.

سجل التحديثات

  • أكتوبر 2026: إضافة المصطلح الإنجليزي بجانب المصطلحات العربية في العناوين والجدول والخلاصة.
  • أكتوبر 2026: كتابة المقال، واختبار كل الأمثلة على Docker مع Go 1.27.1، وOpenJDK 25.0.4.1، وNode.js 22.23.3 مع TypeScript 7.0.2، وPython 3.13.16 مع mypy 2.4.0، وrustc 1.99.0، وgcc 15.2.0، وGHC 9.14.1، وSWI-Prolog 10.1.16، وPostgreSQL 17.11، وTerraform v1.16.5.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.