استخدام الأنواع الجبرية للبيانات (Algebraic Data Types) في .NET Framework / .NET ── تصميم يعبّر عن الحالة والنتيجة بالنوع

· آخر تحديث: · · .NET, .NETFramework, CSharp, FSharp, AlgebraicDataTypes, DiscriminatedUnion, DomainModeling, إعادة استخدام الأصول القائمة

1. أوّل ما ينبغي إدراكه

عند كتابة تطبيقات أعمال بـ.NET، كثيراً ما نصادف قيم إرجاع أو حالات من هذا النوع.

public class CreateUserResult
{
    public bool IsSuccess { get; set; }
    public User User { get; set; }
    public string ErrorCode { get; set; }
    public string ErrorMessage { get; set; }
}

يبدو هذا النوع مفهوماً للوهلة الأولى، لكن يمكن أن تتسلّل إليه حالات كثيرة «غير مرغوب فيها».

فمثلاً، يمكن إنشاء قيم مثل التالية.

  • IsSuccess == true بينما User == null
  • IsSuccess == true بينما ErrorCode يحمل قيمة
  • IsSuccess == false بينما User يحمل قيمة
  • ErrorCode == "DuplicateEmail" بينما ErrorMessage == null
  • أُضيف رمز خطأ جديد، لكن معالجة جهة الاستدعاء لم تُحدَّث

هذا النوع من الأنواع قد يكون مريحاً في البداية، لكن كلّما كبر حجم النظام زاد العبء على القارئ والقائم بالصيانة.

وهنا يأتي دور الفكرة التي نريد استخدامها: الأنواع الجبرية للبيانات.

اسم «الأنواع الجبرية للبيانات» قد يبدو رسمياً بعض الشيء، لكن من الناحية العملية، يسهل فهمه بهذه الطريقة.

التعبير عن «أنّ هذه القيمة تأخذ أشكالاً محدَّدة سلفاً» بالنوع نفسه، لا بالتعليقات أو قواعد التسمية.

فمثلاً، يمكن التعبير عن نتيجة إنشاء المستخدم بأنّها واحدة فقط من الحالات التالية.

CreateUserResult =
  Created(User)
  أو DuplicateEmail(email)
  أو WeakPassword(reason)
  أو SystemFailure(message)

عند النجاح توجد User. عند تكرار البريد الإلكتروني توجد email. عند ضعف كلمة المرور توجد reason. عند خطأ النظام توجد message.

تحمل كلّ حالة البيانات اللازمة فقط. لا يمكن أن يتحقّق النجاح والفشل في آنٍ واحد. ولا يمكن إنشاء حالة نجاح بلا User.

في .NET، يمكن تنفيذ هذه الفكرة عبر الاتحاد المُميَّز في F#، أو تسلسل أصناف sealed، أو تسلسل record، أو مكتبة مثل OneOf في C#، أو مستقبلاً عبر نوع union في C#.

يرتّب هذا المقال طرق استخدام الأنواع الجبرية للبيانات في كلّ من .NET Framework وNET الحالي، ومزاياها ونقاط الانتباه العملية.

علماً بأنّ الشيفرة الواردة في هذا المقال منشورة على GitHub كحزمة أمثلة كاملة قابلة للبناء والتشغيل (مكتبة، وعرض توضيحي (demo) يمثّل كلّ نمط تنفيذ، واختبارات وحدة (unit tests) تتحقّق من شمولية Match، وانتقال الحالة، وتحويل DTO).

dotnet-algebraic-data-types - komurasoft-blog-samples (GitHub)

2. ما هي الأنواع الجبرية للبيانات

تُسمّى الأنواع الجبرية للبيانات بالإنجليزية Algebraic Data Type، وتُختصَر بـADT.

وبعبارة مبسّطة، ADT هو مزيج من نوعين.

  • النوع الضربي (product type): نوع يحمل A وB معاً
  • النوع الاتحادي (sum type): نوع يكون إمّا A أو B

تُستخدَم أصناف .NET وبُناها (structs) وrecord في .NET غالباً باعتبارها «نوعاً ضربياً».

public sealed class Address
{
    public string PostalCode { get; }
    public string Prefecture { get; }
    public string City { get; }
    public string Street { get; }

    public Address(string postalCode, string prefecture, string city, string street)
    {
        PostalCode = postalCode;
        Prefecture = prefecture;
        City = city;
        Street = street;
    }
}

والمعنى هو كالتالي.

Address = PostalCode و Prefecture و City و Street

أمّا النوع الاتحادي، فهو «واحد فقط من بين عدّة».

PaymentResult =
  Succeeded(receiptNo)
  أو InsufficientFunds(shortage)
  أو Rejected(reason)
  أو NetworkFailure(message)

ومعنى هذا كالتالي.

PaymentResult = Succeeded أو InsufficientFunds أو Rejected أو NetworkFailure

والتعبير عن هذا «أو» كنوع هو الجزء الأكثر استخداماً عملياً من الأنواع الجبرية للبيانات.

في F#، يمكن كتابة هذا بشكل طبيعي كميزة لغوية.

type PaymentResult =
    | Succeeded of receiptNo: string
    | InsufficientFunds of shortage: decimal
    | Rejected of reason: string
    | NetworkFailure of message: string

لم يكن لدى C# لفترة طويلة اتحاد مُميَّز كميزة قياسية مثل F#. لذلك، كانت C# تعبِّر عن ذلك عبر تسلسل الأصناف أو المكتبات.

لكن الفكرة نفسها قابلة للاستخدام الكامل في C# أيضاً.

والمهمّ ليس استخدام صياغة (syntax) معيّنة، بل هذه النقطة الوحيدة.

«جعل إنشاء حالة غير صحيحة أمراً مستحيلاً من الأساس»

3. لماذا لا يكفي bool أو enum وحدهما

في المعالجات الصغيرة، قد يبدو bool أو enum كافياً.

على سبيل المثال، قيمة إرجاع كهذه.

public enum PaymentStatus
{
    Succeeded,
    InsufficientFunds,
    Rejected,
    NetworkFailure
}

public sealed class PaymentResponse
{
    public PaymentStatus Status { get; set; }
    public string ReceiptNo { get; set; }
    public decimal? Shortage { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

لكن في هذا الشكل، لا يُعبَّر بالنوع عن العلاقة بين Status وكلّ خاصية.

ReceiptNo مطلوبة فقط عندما تكون Status == Succeeded. Shortage مطلوبة فقط عندما تكون Status == InsufficientFunds. Reason مطلوبة فقط عندما تكون Status == Rejected. Message مطلوبة فقط عندما تكون Status == NetworkFailure.

وهذه القاعدة موجودة خارج الشيفرة.

فهي تعتمد على التعليقات، والمواصفات، والاختبارات، والاتفاقات الضمنية، وذاكرة من نفّذ الشيفرة.

ونتيجة لذلك، تتزايد شيفرات الحماية الدفاعية من هذا النوع.

if (response.Status == PaymentStatus.Succeeded)
{
    if (string.IsNullOrEmpty(response.ReceiptNo))
    {
        throw new InvalidOperationException("ReceiptNo is required.");
    }

    return response.ReceiptNo;
}

قد تكون شيفرات الحماية هذه ضرورية في بعض الحالات، لكن كثيراً منها يمكن منعه أصلاً عبر «تصميم النوع».

وإذا عُبِّر عنها كنوع جبري للبيانات، يمكن جعل كلّ حالة تحمل البيانات اللازمة لها فقط.

Succeeded تحمل receiptNo
InsufficientFunds تحمل shortage
Rejected تحمل reason
NetworkFailure تحمل message

في هذا التصميم، يستحيل إنشاء قيمة من Succeeded بلا receiptNo.

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

4. تنفيذ يعمل حتى في .NET Framework: تسلسل أصناف sealed

أسهل طريقة للتبنّي في الأنظمة القائمة، بما فيها .NET Framework، هي صنف أساس مجرَّد + أصناف sealed متداخلة + طريقة Match.

سهلة الاستخدام حتى في إصدارات C# القديمة، ولا تحتاج إلى أيّ ميزة خاصة من بيئة التشغيل.

لنمثّل نتيجة إنشاء المستخدم كمثال.

public abstract class CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed class Created : CreateUserResult
    {
        internal Created(User user)
        {
            if (user == null) throw new ArgumentNullException(nameof(user));
            User = user;
        }

        public User User { get; }
    }

    public sealed class DuplicateEmail : CreateUserResult
    {
        internal DuplicateEmail(string email)
        {
            if (email == null) throw new ArgumentNullException(nameof(email));
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class WeakPassword : CreateUserResult
    {
        internal WeakPassword(string reason)
        {
            if (reason == null) throw new ArgumentNullException(nameof(reason));
            Reason = reason;
        }

        public string Reason { get; }
    }

    public sealed class SystemFailure : CreateUserResult
    {
        internal SystemFailure(string message)
        {
            if (message == null) throw new ArgumentNullException(nameof(message));
            Message = message;
        }

        public string Message { get; }
    }

    public static CreateUserResult Ok(User user)
        => new Created(user);

    public static CreateUserResult EmailAlreadyUsed(string email)
        => new DuplicateEmail(email);

    public static CreateUserResult PasswordIsWeak(string reason)
        => new WeakPassword(reason);

    public static CreateUserResult Failed(string message)
        => new SystemFailure(message);

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        if (created == null) throw new ArgumentNullException(nameof(created));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (weakPassword == null) throw new ArgumentNullException(nameof(weakPassword));
        if (systemFailure == null) throw new ArgumentNullException(nameof(systemFailure));

        var c = this as Created;
        if (c != null) return created(c);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var w = this as WeakPassword;
        if (w != null) return weakPassword(w);

        var f = this as SystemFailure;
        if (f != null) return systemFailure(f);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

يمكن لجهة الاستخدام أن تكتب هكذا.

CreateUserResult result = service.CreateUser(command);

string message = result.Match(
    created => "تمّ إنشاء المستخدم: " + created.User.Id,
    duplicate => "هذا البريد الإلكتروني مستخدَم بالفعل: " + duplicate.Email,
    weak => "كلمة المرور ضعيفة جدّاً: " + weak.Reason,
    failure => "فشل إنشاء المستخدم: " + failure.Message);

ميزة هذا الشكل أنّه يعمل في .NET Framework وNET الحالي على حدّ سواء.

Created وDuplicateEmail وWeakPassword وSystemFailure كلّها من نوع CreateUserResult، لكنّ البيانات التي تحملها كلّ منها مختلفة.

Created وحدها تحمل User. DuplicateEmail وحدها تحمل Email. WeakPassword وحدها تحمل Reason. SystemFailure وحدها تحمل Message.

لا يمكن إنشاء قيمة تمثّل النجاح والفشل في آنٍ واحد.

وعلاوة على ذلك، إذا اعتادت جهة الاستخدام على استعمال Match، يمكن فرض معالجة جميع الحالات.

لنفترض مثلاً أنّنا أضفنا حالة جديدة اسمها TemporaryBlocked.

public sealed class TemporaryBlocked : CreateUserResult
{
    internal TemporaryBlocked(DateTimeOffset until)
    {
        Until = until;
    }

    public DateTimeOffset Until { get; }
}

عندئذٍ، تُضاف Func<TemporaryBlocked, T> أيضاً إلى معاملات طريقة Match.

عندها، يصبح استدعاء result.Match(...) القائم خطأ ترجمة (compile error). وهذا خطأ جيّد؛ لأنّه يتيح اكتشاف «إضافة حالة جديدة دون أن تتكيّف معها جهة الاستدعاء» وقت الترجمة.

5. جعل المجموعة مغلقة عبر باني (constructor) خاص

المهمّ عند التعبير عن نوع اتحادي في C# هو جعل مجموعة الحالات مغلقة قدر الإمكان.

إذا جُعِل باني الصنف الأساس protected، يبقى هناك مجال للوراثة من الخارج.

public abstract class PaymentResult
{
    protected PaymentResult()
    {
    }
}

في هذا الشكل، يمكن إنشاء نوع كهذا في تجميعة (assembly) أخرى أو مكان آخر.

public sealed class UnknownPaymentResult : PaymentResult
{
}

وعندها، لا تبقى مجموعة حالات PaymentResult مغلقة.

بينما نريد أن نقول «هذا النوع هو إحدى الحالات Succeeded / InsufficientFunds / Rejected / NetworkFailure»، تُضاف حالة أخرى غير مرغوب فيها.

الحلّ الواقعي القابل للاستخدام حتى في .NET Framework هو جعل باني الصنف الأساس private، وتعريف أنواع الحالات كأنواع متداخلة (nested) داخل الصنف الأساس.

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    public sealed class Succeeded : PaymentResult
    {
        internal Succeeded(string receiptNo)
        {
            ReceiptNo = receiptNo;
        }

        public string ReceiptNo { get; }
    }

    public sealed class InsufficientFunds : PaymentResult
    {
        internal InsufficientFunds(decimal shortage)
        {
            Shortage = shortage;
        }

        public decimal Shortage { get; }
    }

    public static PaymentResult Success(string receiptNo)
        => new Succeeded(receiptNo);

    public static PaymentResult Insufficient(decimal shortage)
        => new InsufficientFunds(shortage);
}

يستطيع النوع المتداخل الوصول إلى الأعضاء private للنوع الخارجي. لذلك، الأنواع المتداخلة الخاصّة بالحالات هي وحدها التي يمكنها وراثة PaymentResult.

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

لكن مترجم (compiler) C# لا يقوم بفحص شمولية (exhaustiveness) كامل كما تفعل F#.

لذلك، عند استخدام هذا النمط في C#، يُنصَح بعدم توزيع switch في أماكن متفرّقة قدر الإمكان، وتجميع المعالجة في طريقة Match.

6. في .NET الحالي، يمكن الكتابة بإيجاز عبر تسلسل record

إذا كان الاستهداف .NET 5 فما بعده، يمكن استخدام record في C# لكتابة أنواع الحالات المتمحورة حول البيانات بإيجاز كبير.

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;
}

يمكن لجهة الاستخدام الاعتماد على مطابقة الأنماط (pattern matching) وتعبير switch.

static string ToMessage(CreateUserResult result)
{
    return result switch
    {
        CreateUserResult.Created { User: var user }
            => $"تمّ إنشاء المستخدم: {user.Id}",

        CreateUserResult.DuplicateEmail { Email: var email }
            => $"هذا البريد الإلكتروني مستخدَم بالفعل: {email}",

        CreateUserResult.WeakPassword { Reason: var reason }
            => $"كلمة المرور ضعيفة جدّاً: {reason}",

        CreateUserResult.SystemFailure { Message: var message }
            => $"فشل إنشاء المستخدم: {message}",

        _ => throw new InvalidOperationException("نتيجة غير معروفة.")
    };
}

هذا الأسلوب في الكتابة مقروء ويليق بطابع C#.

ومن ناحية أخرى، هناك نقاط يجب الانتباه إليها.

تسلسل record مفيد لتقليل الشيفرة النمطية (boilerplate) الخاصّة بمقارنة القيم وعرضها. لكن من الأسلم عدم اعتباره آلية لإغلاق مجموعة الحالات بنفس قوة نمط «class عادي + باني خاص + حالات sealed متداخلة» الموضَّح في الفصل السابق.

وبخاصّة في record class غير sealed، تتدخّل أعضاء توليد خاصّة بـrecord مثل باني النسخ (copy constructor). وإذا كان الهدف هو «عدم السماح بالاشتقاق من الخارج مطلقاً» أو «إغلاق مجموعة الحالات بصرامة»، فمن الأصوب اختيار تسلسل class من الفصل السابق، أو الاتحاد المُميَّز في F#، أو مكتبة union / source generator موثوقة.

كما أنّ إدراج _ في تعبير switch هذا يوحي بإمكانية استقبال نوع مشتقّ غير معروف. لكن في تصميم يُعامَل فيه مجموعة الحالات كمجموعة مغلقة، فإنّ _ هو أصلاً تفرّع «لا ينبغي الوصول إليه».

في نطاق الإصدارات المستقرّة القديمة من C#، لا يمكن توقّع فحص شمولية صارم بقدر الاتحاد المُميَّز في F#. لذلك، حتى عند استخدام تسلسل record في C#، من الأسلم الاعتماد على أحد الأمرين التاليين.

  • توفير طريقة Match لفرض معالجة جميع الحالات على جهة الاستدعاء
  • حصر switch في مكان محدَّد وعدم توزيعه في أماكن متفرّقة

يمكن مثلاً إضافة Match إلى تسلسل record أيضاً.

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        return this switch
        {
            Created x => created(x),
            DuplicateEmail x => duplicateEmail(x),
            WeakPassword x => weakPassword(x),
            SystemFailure x => systemFailure(x),
            _ => throw new InvalidOperationException("نتيجة غير معروفة.")
        };
    }
}

بهذا، تستطيع جهة الاستخدام دائماً معالجة جميع الحالات وهي واعية بها.

var message = result.Match(
    created => $"تمّ الإنشاء: {created.User.Id}",
    duplicate => $"مكرَّر: {duplicate.Email}",
    weak => $"كلمة المرور ضعيفة: {weak.Reason}",
    failure => $"فشل: {failure.Message}");

ميزة استخدام record هي تقليل الشيفرة النمطية المتعلّقة بمقارنة القيم وعرضها ونسخها. لكن في مكتبة مشتركة تستهدف .NET Framework أيضاً، قد يكون الكتابة بصنف (class) عادي أسهل تعاملاً من إجبار استخدام record أو خاصية init-only.

من الأفضل إعطاء الأولوية لـ«حصر الحالة المراد التعبير عنها داخل النوع» على «استخدام صياغة جديدة».

7. استخدام الاتحاد المُميَّز في F#

اللغة التي تتعامل مع الأنواع الجبرية للبيانات بأكثر شكل طبيعي في .NET هي F#.

يتوفّر في F# الاتحاد المُميَّز كميزة لغوية جاهزة.

type CreateUserResult =
    | Created of user: User
    | DuplicateEmail of email: string
    | WeakPassword of reason: string
    | SystemFailure of message: string

والاستخدام طبيعي أيضاً.

let toMessage result =
    match result with
    | Created user -> $"تمّ إنشاء المستخدم: {user.Id}"
    | DuplicateEmail email -> $"هذا البريد الإلكتروني مستخدَم بالفعل: {email}"
    | WeakPassword reason -> $"كلمة المرور ضعيفة جدّاً: {reason}"
    | SystemFailure message -> $"فشل إنشاء المستخدم: {message}"

ما يميّز F# هو أنّ تعداد الحالات ومطابقة الأنماط مدمَجان في اللغة نفسها.

عند إضافة حالة، يسهل اكتشاف نقص المعالجة في جانب match. كما يمكن استخدام نوع مثل Option<'T>، الذي يعبِّر عن وجود قيمة أو عدم وجودها، كاتحاد مُميَّز بشكل طبيعي.

let tryFindUser id : User option =
    // إذا وُجِد، Some user، وإن لم يوجد، None
    failwith "sample"

بإرجاع option بدلاً من null، تظهر «إمكانية عدم الوجود» في النوع نفسه.

بما أنّ الاتحاد المُميَّز في F# يُترجَم كنوع من أنواع .NET، يمكن استخدامه في مشاريع F# الموجَّهة لـ.NET Framework وفي مشاريع F# الموجَّهة لـ.NET الحالي على حدّ سواء.

لكن عند التعامل مع الاتحاد المُميَّز الخاص بـF# مباشرةً من C#، قد لا يكون الأمر طبيعياً بقدر التعامل معه داخل F# نفسها.

لذلك، هذا التوزيع للاستخدام واقعي.

  • استخدم الاتحاد المُميَّز في F# بفعالية ضمن منطق النطاق (domain logic) الداخلي في F#
  • في الواجهة العامة (public API) التي تُستدعى كثيراً من C#، حوِّلها إلى DTO أو تسلسل class سهل التعامل من C# أيضاً
  • عند الحدود (boundaries)، اربطها (map) بتمثيل مختلف يناسب متطلّبات JSON أو قاعدة البيانات

إذا أمكن الفصل بين «نوع قوي داخل النطاق» و«نوع سهل التعامل عند الحدود الخارجية»، يصبح الاستخدام سهلاً حتى عند مزج F# وC#.

8. استخدام مكتبة مثل OneOf

إذا أردتَ التعبير عن نوع اتحادي بسهولة في C#، فمكتبة مثل OneOf خيار متاح أيضاً.

يمكن مثلاً التعبير عن القيمة المرجَعة هكذا.

using OneOf;

public sealed class DuplicateEmail
{
    public DuplicateEmail(string email)
    {
        Email = email;
    }

    public string Email { get; }
}

public sealed class WeakPassword
{
    public WeakPassword(string reason)
    {
        Reason = reason;
    }

    public string Reason { get; }
}

public OneOf<User, DuplicateEmail, WeakPassword> CreateUser(CreateUserCommand command)
{
    if (EmailExists(command.Email))
    {
        return new DuplicateEmail(command.Email);
    }

    if (!IsStrongPassword(command.Password))
    {
        return new WeakPassword("يجب أن تكون 12 حرفاً على الأقلّ.");
    }

    return CreateUserCore(command);
}

يمكن لجهة الاستدعاء المعالجة عبر Match.

var result = service.CreateUser(command);

var message = result.Match(
    user => $"تمّ الإنشاء: {user.Id}",
    duplicate => $"مكرَّر: {duplicate.Email}",
    weak => $"كلمة المرور ضعيفة: {weak.Reason}");

يعني OneOf<User, DuplicateEmail, WeakPassword> أنّ «هذه القيمة واحدة فقط من بين User أو DuplicateEmail أو WeakPassword».

ميزة هذه الطريقة أنّها سهلة الاستخدام كقيمة إرجاع محلّية دون الحاجة لإنشاء صنف أساس مخصَّص.

وهي مناسبة بشكل خاص للتعبير عن قيم إرجاع من هذا النوع في طبقة خدمات التطبيق أو حالات الاستخدام (use cases).

نتيجة إنشاء المستخدم = User أو DuplicateEmail أو WeakPassword
نتيجة جلب المنتج = Product أو NotFound أو AccessDenied
نتيجة الدفع = Receipt أو InsufficientFunds أو PaymentRejected

ومن ناحية أخرى، هناك نقاط يجب الانتباه إليها.

إذا عُرِض نوع مثل OneOf<A, B, C> مباشرةً في الواجهة العامة، فقد يضعف الاسم من الناحية الدلالية (domain).

فمثلاً، عند النظر إلى معاملات النوع (type arguments) فقط، يبدو التاليان بنية متشابهة.

OneOf<User, NotFound, AccessDenied> GetUser(...)
OneOf<Order, NotFound, AccessDenied> GetOrder(...)

هذا مفيد في نطاق صغير، لكن إذا أردتَ توضيح المعنى الدلالي (domain)، فإنشاء نوع مخصَّص أكثر قابلية للقراءة.

public abstract class GetUserResult
{
    // Found / NotFound / AccessDenied
}

ومعيار اختيار الاستخدام هو كالتالي.

  • إذا كانت القيمة المرجَعة محلّية، فـOneOf مفيدة
  • إذا كان المفهوم يتكرّر ظهوره في النطاق (domain)، أنشئ نوعاً مخصَّصاً
  • إذا كانت أولويتك استقرار الواجهة العامة، اجعله نوع نتيجة له اسم واضح

علماً بأنّ OneOf قابلة للاستخدام في نطاق واسع من الأهداف (targets) يشمل .NET Framework وNET Standard، لذا فهي خيار سهل التبنّي حتى في أصول .NET Framework القائمة.

9. استخدام مكتبات مبنيّة على Source Generator

في .NET الحالي، توجد أيضاً مكتبات تولِّد أنواعاً شبيهة بالاتحاد المُميَّز باستخدام Source Generator.

فمثلاً، هناك مكتبات تولِّد شيفرة Switch وMap والتحقّق (validation) والتكامل مع التسلسل (serialization) بمجرّد إضافة سمة (attribute).

وشكلها التقريبي كالتالي.

[Union]
public partial record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record Failure(string Error) : Result<T>;
}

يمكن لمكتبات كهذه تقليل الشيفرة النمطية المكتوبة يدوياً لـMatch أوSwitch. كما أنّ بعضها يجمع بين ذلك وبين Analyzer لتحذيرك من نقص المعالجة.

لكن عند الاستخدام في نظام قائم يشمل .NET Framework، تحقّق من النقاط التالية.

  • هل الـTFM المستهدَف يدعم .NET Framework؟
  • هل بيئة SDK / Visual Studio / MSBuild اللازمة لاستخدام Source Generator متوفّرة؟
  • هل تُنتِج بيئة CI نفس نتيجة التوليد؟
  • هل يمكن تصحيح أخطاء (debug) الشيفرة المولَّدة؟
  • هل يعمل التكامل مع JSON / قاعدة البيانات / OpenAPI عند حدود التطبيق كما هو متوقَّع؟

وبخاصّة في مشاريع .NET Framework القديمة، قد لا تعمل الحزم المبنيّة على افتراض وجود Source Generator كما هي.

إذا أردتَ دعماً قوياً لـ.NET Framework، فالأسلم البدء بتسلسل class مكتوب يدوياً أو بـOneOf.

10. حول نوع union في C# 15

حتى يونيو 2026، ظهر نوع union في C# 15 كميزة معاينة (preview).

في اتجاه هذه المعاينة، يمكن الإعلان بأنّ «هذا النوع هو واحد فقط من الأنواع المحدَّدة».

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

public union Pet(Cat, Dog, Bird);

تتعامل جهة الاستخدام مع كلّ حالة عبر مطابقة الأنماط.

static string Describe(Pet pet)
{
    return pet switch
    {
        Cat cat => $"Cat: {cat.Name}",
        Dog dog => $"Dog: {dog.Name}",
        Bird bird => $"Bird: {bird.Name}",
        Pet { Value: null } => "Unknown pet"
    };
}

إذا استقرّت هذه الميزة، سيصبح بالإمكان التعامل في C# أيضاً مع «مجموعة أنواع مغلقة» و«مطابقة أنماط شاملة (exhaustive)» بشكل أكثر طبيعية.

إذا كان النوع المولَّد وقت المعاينة من نوع struct، فقد تمرَّر أيضاً قيمة مثل default(Pet) حيث تكون Value الداخلية null. وعند استقبال قيمة union في طريقة عامة (public)، يجب التعامل بحذر (defensively) مع هذه القيمة الافتراضية أيضاً.

لكن ينبغي تقييم ميزات المعاينة بعناية قبل إدخالها في شيفرة الإنتاج الفعلية.

قد تتغيّر مواصفة اللغة، ودعم بيئة التطوير المتكاملة (IDE)، والأنواع المساعدة في بيئة التشغيل، والـAnalyzer، والتكامل مع أدوات التسلسل (serializers) قبل الإصدار الرسمي.

لذلك، الموقف الواقعي حالياً في العمل الفعلي هو كالتالي.

  • تستحقّ union في C# التجربة في التحقّقات الجديدة أو الأبحاث التقنية
  • في الشيفرة التي تُصان طويل الأمد في الإنتاج، استخدم خيارات مستقرّة مثل DU في F#، أو تسلسل class/record، أو OneOf، أو Source Generator
  • رتِّب قيم الإرجاع والحالات كنوع «واحد فقط من بين عدّة» تسهيلاً للانتقال إلى union في C# مستقبلاً

بمعنى آخر، يمكن البدء بتصميم على طراز ADT اليوم دون انتظار union في C#.

بل إنّ ترتيب أنواع Result وOption وأنواع الحالة وأنواع أحداث النطاق (domain events) منذ الآن يسهِّل الانتقال إلى الميزة اللغوية مستقبلاً.

11. نوع Option: التعبير عن «عدم الوجود» بدلاً من null

المثال النموذجي على الأنواع الجبرية للبيانات هو Option<T>.

يعبِّر Option<T> عن واحدة من الحالتين التاليتين.

Some(value)
None

في C#، غالباً ما يُعبَّر عن «عدم الوجود» بـnull، لكنّ لـnull مشكلة أنّه غير ظاهر من النوع بسهولة.

User user = repository.FindById(id);

// يجب أن تتذكّر جهة الاستدعاء ما إذا كان user يمكن أن يكون null أم لا
Console.WriteLine(user.Name);

باستخدام Option<User>، تظهر «احتمالية عدم العثور عليه» في النوع نفسه.

إليك تنفيذاً بسيطاً يعمل حتى في .NET Framework.

public abstract class Option<T>
{
    private Option()
    {
    }

    public sealed class Some : Option<T>
    {
        internal Some(T value)
        {
            Value = value;
        }

        public T Value { get; }
    }

    public sealed class None : Option<T>
    {
        internal None()
        {
        }
    }

    private static readonly None NoneValue = new None();

    public static Option<T> Of(T value)
    {
        if (object.Equals(value, null))
        {
            return NoneValue;
        }

        return new Some(value);
    }

    public static Option<T> Empty()
    {
        return NoneValue;
    }

    public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
    {
        if (some == null) throw new ArgumentNullException(nameof(some));
        if (none == null) throw new ArgumentNullException(nameof(none));

        var s = this as Some;
        if (s != null) return some(s.Value);

        return none();
    }
}

يصبح شكل الاستخدام كالتالي.

Option<User> user = repository.FindById(id);

string displayName = user.Match(
    some: u => u.Name,
    none: () => "ضيف");

لا حاجة للتخلّص من null تماماً. فـnull يظهر في واجهات .NET القائمة، وقواعد البيانات، وJSON.

لكن داخل منطق النطاق (domain logic)، هناك مواضع كثيرة يكون فيها Option<T> أوضح نيّة من null.

وOption<T> مناسب بشكل خاص في طرق (methods) من هذا النوع.

Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);

والنقطة الجوهرية هي التعبير عن «احتمالية عدم الوجود» في نوع القيمة المرجَعة، لا في اسم الطريقة بإضافة Try فقط.

12. نوع Result: إرجاع الفشل المتوقَّع كنوع

نوع آخر شائع الاستخدام هو Result<TSuccess, TError>.

يعبِّر عن واحدة من الحالتين التاليتين.

Success(value)
Failure(error)

الاستثناءات مناسبة للفشل غير المتوقَّع، أو الفشل الذي لا تريد وضعه ضمن مسار التحكّم (control flow) الاعتيادي. ومن ناحية أخرى، الفشل الذي يحدث كثيراً على المستوى التجاري يكون أوضح غالباً عند إرجاعه كنوع.

فمثلاً، في معالجة تسجيل الدخول، هذه الأنواع من الفشل متوقَّعة.

  • المستخدم غير موجود
  • كلمة المرور خاطئة
  • الحساب مقفَل
  • المصادقة متعدّدة العوامل مطلوبة

إذا عُبِّر عن هذا بالاستثناءات فقط، ستكتب جهة الاستدعاء تفرّع العمل داخل catch.

try
{
    var session = auth.Login(userName, password);
    return Ok(session);
}
catch (InvalidPasswordException)
{
    return Unauthorized();
}
catch (AccountLockedException)
{
    return Forbid();
}

يعمل هذا حتى لو كُتِب بالاستثناءات، لكنّ تفرّع العمل يسهل أن يضيع داخل معالجة الاستثناءات.

إذا عُبِّر عنه بأسلوب ADT، يصبح الشكل كالتالي.

public abstract class LoginResult
{
    private LoginResult()
    {
    }

    public sealed class Succeeded : LoginResult
    {
        internal Succeeded(Session session)
        {
            Session = session;
        }

        public Session Session { get; }
    }

    public sealed class InvalidPassword : LoginResult
    {
        internal InvalidPassword()
        {
        }
    }

    public sealed class AccountLocked : LoginResult
    {
        internal AccountLocked(DateTimeOffset until)
        {
            Until = until;
        }

        public DateTimeOffset Until { get; }
    }

    public sealed class MfaRequired : LoginResult
    {
        internal MfaRequired(string challengeId)
        {
            ChallengeId = challengeId;
        }

        public string ChallengeId { get; }
    }

    public static LoginResult Success(Session session)
        => new Succeeded(session);

    public static LoginResult WrongPassword()
        => new InvalidPassword();

    public static LoginResult Locked(DateTimeOffset until)
        => new AccountLocked(until);

    public static LoginResult RequireMfa(string challengeId)
        => new MfaRequired(challengeId);

    public T Match<T>(
        Func<Succeeded, T> succeeded,
        Func<InvalidPassword, T> invalidPassword,
        Func<AccountLocked, T> accountLocked,
        Func<MfaRequired, T> mfaRequired)
    {
        if (succeeded == null) throw new ArgumentNullException(nameof(succeeded));
        if (invalidPassword == null) throw new ArgumentNullException(nameof(invalidPassword));
        if (accountLocked == null) throw new ArgumentNullException(nameof(accountLocked));
        if (mfaRequired == null) throw new ArgumentNullException(nameof(mfaRequired));

        var s = this as Succeeded;
        if (s != null) return succeeded(s);

        var i = this as InvalidPassword;
        if (i != null) return invalidPassword(i);

        var l = this as AccountLocked;
        if (l != null) return accountLocked(l);

        var m = this as MfaRequired;
        if (m != null) return mfaRequired(m);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

هذا الشكل يجعل جهة الاستدعاء تنفّذ وهي ترى «النتائج الممكنة لمعالجة تسجيل الدخول».

var result = auth.Login(userName, password);

return result.Match(
    succeeded => Ok(succeeded.Session),
    invalidPassword => Unauthorized(),
    accountLocked => StatusCode(423),
    mfaRequired => Accepted(new { mfaRequired.ChallengeId }));

النقطة الجوهرية ليست التخلّي عن الاستثناءات.

اجعل التوزيع: Result للتفرّع التجاري المتوقَّع، والاستثناء للحالة الشاذّة غير المتوقَّعة.

هذا وحده يحسِّن كثيراً وضوح طبقة خدمات التطبيق وطبقة الـAPI.

13. التعبير عن انتقال الحالة بالنوع

ADT مناسب ليس فقط للقيم المرجَعة، بل أيضاً للتعبير عن الحالة.

لنأخذ حالة الطلب (order) كمثال.

public enum OrderStatus
{
    Draft,
    Submitted,
    Paid,
    Shipped,
    Cancelled
}

بـenum وحده، يصعب التعبير عن البيانات اللازمة لكلّ حالة.

  • Draft تحتاج إلى مُنشِئها
  • Submitted تحتاج إلى تاريخ ووقت التقديم
  • Paid تحتاج إلى رقم الدفع
  • Shipped تحتاج إلى رقم الشحن
  • Cancelled تحتاج إلى سبب الإلغاء

وإذا حاولنا التعبير عن ذلك بخصائص منفصلة إلى جانب OrderStatus، تتزايد الخصائص القابلة لـnull مجدّداً.

public sealed class Order
{
    public OrderStatus Status { get; set; }
    public DateTimeOffset? SubmittedAt { get; set; }
    public string PaymentNo { get; set; }
    public string TrackingNo { get; set; }
    public string CancelReason { get; set; }
}

في هذا التصميم، يمكن إنشاء حالة يكون فيها Status == Draft بينما TrackingNo يحمل قيمة.

إذا عُبِّر عنه بأسلوب ADT، تُجعَل الحالة نفسها نوعاً.

public abstract class OrderState
{
    private OrderState()
    {
    }

    public sealed class Draft : OrderState
    {
        internal Draft(UserId createdBy)
        {
            CreatedBy = createdBy;
        }

        public UserId CreatedBy { get; }
    }

    public sealed class Submitted : OrderState
    {
        internal Submitted(DateTimeOffset submittedAt)
        {
            SubmittedAt = submittedAt;
        }

        public DateTimeOffset SubmittedAt { get; }
    }

    public sealed class Paid : OrderState
    {
        internal Paid(string paymentNo)
        {
            PaymentNo = paymentNo;
        }

        public string PaymentNo { get; }
    }

    public sealed class Shipped : OrderState
    {
        internal Shipped(string trackingNo)
        {
            TrackingNo = trackingNo;
        }

        public string TrackingNo { get; }
    }

    public sealed class Cancelled : OrderState
    {
        internal Cancelled(string reason)
        {
            Reason = reason;
        }

        public string Reason { get; }
    }
}

يحمل الطلب OrderState.

public sealed class Order
{
    public OrderId Id { get; }
    public OrderState State { get; private set; }

    public Order(OrderId id, UserId createdBy)
    {
        Id = id;
        State = new OrderState.Draft(createdBy);
    }
}

وعلاوة على ذلك، نحصر انتقال الحالة داخل الطرق (methods).

public void Submit(IClock clock)
{
    if (!(State is OrderState.Draft))
    {
        throw new InvalidOperationException("يمكن تقديم الطلبات في حالة المسودة فقط.");
    }

    State = new OrderState.Submitted(clock.Now);
}

public void MarkAsPaid(string paymentNo)
{
    if (!(State is OrderState.Submitted))
    {
        throw new InvalidOperationException("يمكن تحويل الطلبات المُقدَّمة فقط إلى مدفوعة.");
    }

    State = new OrderState.Paid(paymentNo);
}

بهذا الشكل، تصبح البيانات الخاصّة بكلّ حالة وقواعد انتقال الحالة أكثر قابلية للقراءة.

وبالطبع، عند الحفظ الدائم (persistence)، قد يُخزَّن الأمر مقسَّماً إلى OrderStatus وأعمدة مساعدة.

وحتى في هذه الحالة، يمكن التعامل معه داخل النطاق كـOrderState، والتحويل عند الحدود مع قاعدة البيانات.

التمثيل في قاعدة البيانات
  status = "Paid"
  payment_no = "PAY-001"

التمثيل داخل النطاق
  OrderState.Paid("PAY-001")

لا داعي لإضعاف نموذج النطاق (domain model) لمجاراة مخطّط (schema) قاعدة البيانات.

14. التحويل إلى DTO عند حدود الـAPI

أنواع ADT مفيدة جدّاً داخل النطاق.

لكن عند التعامل مع JSON API، وقاعدة البيانات، وطوابير الرسائل (message queues)، وOpenAPI، والتكامل الخارجي، يلزم بعض الحذر.

لنفترض مثلاً إخراج هذا الـADT مباشرةً إلى JSON.

public abstract record PaymentResult
{
    public sealed record Succeeded(string ReceiptNo) : PaymentResult;
    public sealed record Rejected(string Reason) : PaymentResult;
    public sealed record NetworkFailure(string Message) : PaymentResult;
}

قد تريد أن يكون شكل JSON كالتالي.

{
  "type": "succeeded",
  "receiptNo": "R-001"
}

وفي حال الفشل، الشكل كالتالي.

{
  "type": "rejected",
  "reason": "card_expired"
}

هذا الحقل type هو المميِّز (discriminator) في جانب JSON.

تمثيل ADT في النطاق وتمثيل JSON متشابهان لكنّهما ليسا نفس الشيء.

لذلك، التصميم الآمن هو التحويل إلى DTO عند الحدود الخارجية.

public sealed class PaymentResultDto
{
    public string Type { get; set; }
    public string ReceiptNo { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

في معالجة التحويل، يُنشَأ DTO لكلّ حالة من حالات الـADT.

public static PaymentResultDto ToDto(PaymentResult result)
{
    return result switch
    {
        PaymentResult.Succeeded x => new PaymentResultDto
        {
            Type = "succeeded",
            ReceiptNo = x.ReceiptNo
        },

        PaymentResult.Rejected x => new PaymentResultDto
        {
            Type = "rejected",
            Reason = x.Reason
        },

        PaymentResult.NetworkFailure x => new PaymentResultDto
        {
            Type = "network_failure",
            Message = x.Message
        },

        _ => throw new InvalidOperationException("نتيجة دفع غير معروفة.")
    };
}

وبالطبع، هناك أيضاً طريقة استخدام التسلسل متعدّد الأشكال (polymorphic serialization) في System.Text.Json أو محوِّلات (converters) مخصَّصة.

لكن في الـAPI الذي يُصان طويل الأمد، غالباً ما يكون من الأسلم عدم ربط شكل JSON بإحكام ببنية النوع الداخلية للنطاق.

والفصل المُوصى به هو كالتالي.

داخل النطاق
  PaymentResult.Succeeded
  PaymentResult.Rejected
  PaymentResult.NetworkFailure

عند حدود الـAPI
  PaymentResultDto
  type: "succeeded" | "rejected" | "network_failure"

اجعل نوع النطاق مركِّزاً على التعبير عن العمل التجاري، واجعل التمثيل الخارجي مستقرّاً عبر DTO.

وبهذا الفصل، يسهل الحفاظ على توافق الـAPI حتى عند تحسين النطاق داخلياً.

15. الميزة الأولى: صعوبة إنشاء حالة غير صحيحة

أكبر ميزة لـADT هي صعوبة إنشاء حالة غير صحيحة.

على سبيل المثال، يمكن بسهولة إنشاء تركيبات غير صحيحة بنوع كهذا.

public sealed class Reservation
{
    public bool IsCancelled { get; set; }
    public DateTimeOffset? CancelledAt { get; set; }
    public string CancelReason { get; set; }
    public DateTimeOffset? ConfirmedAt { get; set; }
}

يمكن إنشاء حالات مثل التالية بهذا النوع.

  • CancelledAt موجود رغم عدم الإلغاء
  • CancelReason غير موجود رغم الإلغاء
  • ConfirmedAt موجود رغم أنّ الحجز مُلغى بالفعل
  • تاريخ التأكيد موجود قبل التأكيد نفسه

عند التعبير بأسلوب ADT، يمكن فصل البيانات اللازمة لكلّ حالة.

public abstract class ReservationState
{
    private ReservationState()
    {
    }

    public sealed class Requested : ReservationState
    {
        internal Requested(DateTimeOffset requestedAt)
        {
            RequestedAt = requestedAt;
        }

        public DateTimeOffset RequestedAt { get; }
    }

    public sealed class Confirmed : ReservationState
    {
        internal Confirmed(DateTimeOffset confirmedAt)
        {
            ConfirmedAt = confirmedAt;
        }

        public DateTimeOffset ConfirmedAt { get; }
    }

    public sealed class Cancelled : ReservationState
    {
        internal Cancelled(DateTimeOffset cancelledAt, string reason)
        {
            CancelledAt = cancelledAt;
            Reason = reason;
        }

        public DateTimeOffset CancelledAt { get; }
        public string Reason { get; }
    }
}

بهذا، تحمل حالة الإلغاء وحدها تاريخ الإلغاء وسببه.

بدلاً من فحص التركيبات غير الصحيحة لاحقاً، يمكن تقليلها وقت التصميم نفسه.

وهذا مهمّ أيضاً من زاوية الاختبار.

عندما تتزايد خصائص bool والخصائص القابلة لـnull، يتفجّر عدد التركيبات الممكنة. وعند التحوّل إلى ADT، تُرتَّب الحالات الواجب اختبارها في «الحالات المعرَّفة» فقط.

16. الميزة الثانية: تنبيه جهة الاستدعاء لنقص المعالجة

يُظهِر ADT لجهة الاستدعاء «ما هي الحالات الممكنة لهذه القيمة».

فمثلاً، عند رؤية قيمة الإرجاع التالية، تدرك جهة الاستدعاء أنّها بحاجة إلى معالجة Found وNotFound وForbidden.

public abstract class GetDocumentResult
{
    private GetDocumentResult()
    {
    }

    public sealed class Found : GetDocumentResult
    {
        internal Found(Document document)
        {
            Document = document;
        }

        public Document Document { get; }
    }

    public sealed class NotFound : GetDocumentResult
    {
        internal NotFound(DocumentId id)
        {
            Id = id;
        }

        public DocumentId Id { get; }
    }

    public sealed class Forbidden : GetDocumentResult
    {
        internal Forbidden(UserId userId)
        {
            UserId = userId;
        }

        public UserId UserId { get; }
    }

    public static GetDocumentResult DocumentFound(Document document)
        => new Found(document);

    public static GetDocumentResult DocumentNotFound(DocumentId id)
        => new NotFound(id);

    public static GetDocumentResult AccessForbidden(UserId userId)
        => new Forbidden(userId);

    public T Match<T>(
        Func<Found, T> found,
        Func<NotFound, T> notFound,
        Func<Forbidden, T> forbidden)
    {
        if (found == null) throw new ArgumentNullException(nameof(found));
        if (notFound == null) throw new ArgumentNullException(nameof(notFound));
        if (forbidden == null) throw new ArgumentNullException(nameof(forbidden));

        var f = this as Found;
        if (f != null) return found(f);

        var n = this as NotFound;
        if (n != null) return notFound(n);

        var d = this as Forbidden;
        if (d != null) return forbidden(d);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

إذا اكتُفي بإرجاع null، لا يمكن معرفة ما إذا كان السبب «عدم الوجود» أم «عدم الصلاحية» أم «فشل معالجة الجلب».

وإذا اكتُفي بالاستثناءات، يصعب معرفة أيّ استثناء متوقَّع على المستوى التجاري.

عند التعبير عنها كـGetDocumentResult، يصبح توقيع الطريقة نفسه مواصفة.

GetDocumentResult GetDocument(UserId userId, DocumentId documentId);

هذه الطريقة لا تكتفي بإرجاع المستند.

بل تحمل عقد API يقول إنّها تُرجِع إحدى الحالات: «وُجِد» أو «لم يوجد» أو «لا صلاحية».

وعلاوة على ذلك، باستخدام Match، يسهل الانتباه لنقص المعالجة.

return result.Match(
    found => Ok(found.Document),
    notFound => NotFound(),
    forbidden => Forbid());

عند إضافة حالة جديدة، إذا زادت معاملات Match، يسهل اكتشاف نقص تحديث جهة الاستدعاء وقت الترجمة.

وهذا فعّال للغاية في الصيانة طويلة الأمد.

17. الميزة الثالثة: بقاء مصطلحات النطاق في الشيفرة

عندما يُعبَّر عن الحالة بـbool أوint أوstring أوnull فقط، يختفي المعنى التجاري من الشيفرة.

return false;

فماذا تعني false هذه؟

  • لم يُعثَر عليه
  • المُدخَل غير صحيح
  • لا صلاحية
  • الخدمة الخارجية متوقّفة
  • تمّت معالجته بالفعل

لا يمكن معرفة السبب ما لم تكن جهة الاستدعاء على علم بالسياق.

باستخدام ADT، تبقى المصطلحات التجارية موجودة كنوع.

return GetDocumentResult.DocumentNotFound(documentId);
return GetDocumentResult.AccessForbidden(userId);
return SubmitOrderResult.AlreadySubmitted(orderId);
return SubmitOrderResult.CreditLimitExceeded(limit);

هذا الفرق كبير.

تصبح مصطلحات النطاق ظاهرة في مراجعة الشيفرة، وفي السجلّات (logs)، وفي الاختبارات على حدّ سواء.

فمثلاً، تصبح أسماء الاختبارات طبيعية أيضاً.

[Fact]
public void يُعيد_AlreadySubmitted_عند_إعادة_تقديم_طلب_سبق_تقديمه()
{
    var result = service.Submit(orderId);

    Assert.IsType<SubmitOrderResult.AlreadySubmitted>(result);
}

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

18. الميزة الرابعة: تقليل الإفراط في استخدام الاستثناءات

استثناءات .NET قوية.

لكن إذا حُوِّل حتى التفرّع الذي يحدث كثيراً على المستوى التجاري إلى استثناء، فقد يسوء وضوح المعالجة.

لنأخذ حجز المخزون (stock reservation) كمثال.

نقص المخزون ليس حالة شاذّة بالنسبة للنظام. بل هو نتيجة تحدث بشكل اعتيادي على المستوى التجاري.

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<OutOfStock, T> outOfStock)
    {
        if (reserved == null) throw new ArgumentNullException(nameof(reserved));
        if (outOfStock == null) throw new ArgumentNullException(nameof(outOfStock));

        var r = this as Reserved;
        if (r != null) return reserved(r);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

بهذا التعبير، يصبح نقص المخزون نتيجة اعتيادية باسم OutOfStock.

var result = stock.Reserve(sku, quantity);

return result.Match(
    reserved => Ok(reserved.ReservationId),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

ومن ناحية أخرى، حالات مثل انقطاع اتّصال قاعدة البيانات، أو تلف ملفّ الإعدادات، أو حدوث تضارب غير متوقَّع، يجوز التعبير عنها باستثناءات.

وكحدّ فاصل للحكم، هذا التقسيم عملي.

ما ينبغي أن تتعامل معه جهة الاستدعاء كتفرّع اعتيادي
  => أرجعه عبر Result / ADT

ما لا يمكن التعافي منه في المعالجة الاعتيادية
  => اجعله استثناءً

بهذا التوزيع، يمكن تجنّب أن يصبح try-catch بديلاً عن تفرّع العمل.

19. الميزة الخامسة: سهولة كتابة الاختبارات

باستخدام ADT، تصبح الحالات المستهدَفة بالاختبار واضحة.

لنفترض وجود نوع النتيجة التالي.

SubmitOrderResult =
  Submitted(orderId)
  أو AlreadySubmitted(orderId)
  أو InvalidOrder(reason)
  أو CreditLimitExceeded(limit)

في هذه الحالة، تنقسم الاختبارات طبيعياً حسب كلّ حالة.

إذا كان الطلب صحيحاً، يُرجَع Submitted
إذا كان مُقدَّماً بالفعل، يُرجَع AlreadySubmitted
إذا كان الطلب غير صحيح، يُرجَع InvalidOrder
إذا تجاوز حدّ الائتمان، يُرجَع CreditLimitExceeded

عندما تُمثَّل الحالة بتركيبة من خصائص قابلة لـnull، يجب أن يفهم جانب الاختبار أيضاً «أيّ تركيبة صحيحة».

أمّا مع ADT، فالحالة نفسها تصبح زاوية الاختبار.

كما تصبح بيانات الاختبار أسهل إنشاءً.

var result = SubmitOrderResult.CreditLimitExceeded(limit);

بهذا السطر الواحد، يمكن إنشاء بيانات تحمل معنى «تجاوز حدّ الائتمان».

هذا أوضح نيّةً من الجمع بين Status وErrorCode وMessage وLimit لإنشاء كائن يبدو مشابهاً.

20. سياسة التبنّي لأجل .NET Framework

عند تبنّي تصميم على طراز ADT في نظام قائم على .NET Framework، من الأفضل عدم إجراء تغيير كبير مفاجئ.

المُوصى به هو البدء بقيم الإرجاع أوّلاً. ابحث في الشيفرة القائمة عن أشياء من هذا النوع.

  • طريقة bool TryXxx(...) لكن أصبح سبب الفشل مطلوباً أيضاً
  • إرجاع null بينما توجد عدّة أسباب لعدم العثور
  • تزايد enum Status مع خصائص مساعدة قابلة لـnull
  • التعبير عن تفرّع العمل عبر الاستثناءات
  • انتشار مقارنة السلاسل النصّية لـErrorCode

مثل هذه المواضع تظهر فيها فائدة التحوّل إلى ADT بسهولة.

بعد ذلك، أنشئ نوع نتيجة مخصَّصاً.

public abstract class RegisterMemberResult
{
    private RegisterMemberResult()
    {
    }

    public sealed class Registered : RegisterMemberResult
    {
        internal Registered(MemberId memberId)
        {
            MemberId = memberId;
        }

        public MemberId MemberId { get; }
    }

    public sealed class DuplicateEmail : RegisterMemberResult
    {
        internal DuplicateEmail(string email)
        {
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class InvalidInvitationCode : RegisterMemberResult
    {
        internal InvalidInvitationCode(string code)
        {
            Code = code;
        }

        public string Code { get; }
    }

    public T Match<T>(
        Func<Registered, T> registered,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<InvalidInvitationCode, T> invalidInvitationCode)
    {
        if (registered == null) throw new ArgumentNullException(nameof(registered));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (invalidInvitationCode == null) throw new ArgumentNullException(nameof(invalidInvitationCode));

        var r = this as Registered;
        if (r != null) return registered(r);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var i = this as InvalidInvitationCode;
        if (i != null) return invalidInvitationCode(i);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

ثمّ، عند حدود الـAPI القائمة، حوِّله فوراً إلى DTO أو الصيغة القديمة.

var result = service.Register(command);

return result.Match(
    registered => new RegisterMemberResponse
    {
        Success = true,
        MemberId = registered.MemberId.Value
    },
    duplicate => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "DuplicateEmail",
        ErrorMessage = duplicate.Email + " مستخدَم بالفعل."
    },
    invalidCode => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "InvalidInvitationCode",
        ErrorMessage = "رمز الدعوة غير صالح."
    });

يمكن تقوية المنطق الداخلي أوّلاً دون الحاجة لتغيير الواجهة الخارجية فوراً.

وهذا مهمّ للغاية في الأنظمة القائمة.

اعتبارات الـAPI الخارجي أو الشاشة
  الحفاظ على صيغة الاستجابة الحالية

منطق النطاق الداخلي
  المعالجة الآمنة بنوع على طراز ADT

حتى مجرّد التحويل عند الحدود يرتّب التفرّع الداخلي بشكل كبير.

21. جعلها مكتبة مشتركة عبر .NET Standard

بالنسبة لمكتبة تُستخدَم من كلّ من .NET Framework وNET الحالي، هناك خيار استخدام .NET Standard.

وبخاصّة إذا كانت الأولوية للتوافق الواسع، فإنّ .NET Standard 2.0 مرشَّح واقعي.

يمكن مثلاً وضع نموذج النطاق وأنواع النتائج في مكتبة بهذه البنية.

MyApp.Domain
  TargetFramework: netstandard2.0

MyApp.LegacyWeb
  TargetFramework: net472
  يشير إلى MyApp.Domain

MyApp.Api
  TargetFramework: net8.0
  يشير إلى MyApp.Domain

بهذه البنية، يسهل مشاركة نفس أنواع النطاق بين تطبيق .NET Framework القديم وتطبيق .NET الجديد.

لكن عند استهداف .NET Standard 2.0، تجنَّب الاعتماد المفرط على واجهات C# / .NET الحديثة.

فمثلاً، من الأسلم تجنّب هذه التصاميم في المكتبة المشتركة أحياناً.

  • الاعتماد الشديد على record أوinit
  • استخدام واجهات .NET 6 فما بعده مباشرةً
  • نشر شيفرة مبنيّة على افتراض وجود Source Generator على نطاق واسع
  • إدخال أنواع خاصّة بـASP.NET Core داخل طبقة النطاق

في المكتبة المشتركة، التركيز على class بسيط، وكائنات القيمة (value objects)، وأنواع نتائج على طراز ADT، يجعلها سهلة الاستخدام لفترة طويلة.

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    // يُعبَّر عنه بصنف (class) عادي، سهل الاستخدام في .NET Framework وNET على حدّ سواء
}

أمّا في طبقة التطبيق المخصَّصة لـ.NET الجديد فقط، فيمكن استخدام record وتعبير switch.

طبقة النطاق المشتركة
  أنواع عادية يمكن قراءتها حتى في البيئات القديمة

طبقة التطبيق الجديدة
  الاستفادة من record / مطابقة الأنماط / minimal API وما شابه

وبهذا الفصل، يسهل تحقيق التوازن بين الأصول القائمة والتطوير الجديد.

22. إلى أيّ حدّ ينبغي استخدام ADT

ADT مفيد، لكن ليس كلّ شيء ينبغي تحويله إلى ADT.

المناسب هو ما تكون فيه مجموعة الحالات مغلقة تقريباً على المستوى التجاري. ومن الأمثلة على ذلك ما يلي.

  • نتيجة المعالجة
  • نتيجة التحقّق من المُدخَل
  • حالة الطلب
  • نتيجة الدفع
  • نتيجة المصادقة
  • نتيجة استدعاء خدمة خارجية
  • حدث النطاق (domain event)
  • نوع الأمر (command)
  • حالة الشاشة

وبالمقابل، هناك ما يحتاج إلى حذر.

  • ما تزداد أنواعه من الخارج عبر الإضافات (plugins)
  • ما تزداد أنواعه بتعريف من المستخدم
  • ما يزداد أثناء التشغيل كبيانات رئيسية (master data) في قاعدة البيانات
  • الأنواع المتكاملة مع إطار عمل (framework) تفترض التوسّع بالوراثة
  • DTO بسيط لعمليّات CRUD

إذا كان التصميم يسمح بزيادة الحالات من الخارج، فالواجهة (interface) أو تسلسل الوراثة العادي أنسب من ADT مغلق.

فمثلاً، إذا كانت صيغ إخراج التقارير تزداد عبر الإضافات، فهذا التصميم طبيعي.

public interface IReportExporter
{
    string FormatName { get; }
    void Export(Report report, Stream output);
}

في هذه الحالة، إذا جُعِل نوعاً اتحادياً مغلقاً مثل PdfExporter | ExcelExporter | CsvExporter، يصبح التوسّع الخارجي صعباً.

ADT تصميم قوي في «عالم مغلق».

هل هو مغلق فعلاً على المستوى التجاري؟ وهل هناك احتمال لزيادته من الخارج مستقبلاً؟

تمييز ذلك هو الأمر المهمّ.

23. التمييز في الاستخدام مع enum

enum ليس سيّئاً بحدّ ذاته.

enum مناسب عندما لا تحمل كلّ حالة بيانات إضافية، ويكفي أن تكون تسمية (label) بسيطة. ومن الأمثلة على ذلك ما يلي.

public enum Gender
{
    Unknown,
    Male,
    Female,
    Other
}

أو شيء مثل مستوى السجلّ (log level).

public enum LogLevel
{
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Critical
}

ومن ناحية أخرى، إذا كانت البيانات اللازمة مختلفة لكلّ حالة، فكِّر في نوع على طراز ADT.

PaymentStatus enum
  Succeeded
  Rejected
  Failed

PaymentResult ADT
  Succeeded(receiptNo)
  Rejected(reason)
  Failed(message)

ومعيار التمييز بسيط.

يكفي معرفة الحالة فقط
  => enum

البيانات التي تحملها كلّ حالة مختلفة
  => ADT

السلوك أو القيود تختلف لكلّ حالة
  => ADT أو تسلسل class

عندما يبدأ «enum + مجموعة خصائص قابلة لـnull» بالتزايد، فتلك إشارة إلى التحوّل إلى ADT.

24. التمييز في الاستخدام مع bool

bool أيضاً ليس سيّئاً بحدّ ذاته.

إذا كان المعنى يكتمل فعلاً بـ«نعم/لا» فقط، فـbool كافٍ.

bool IsEnabled { get; }
bool IsDeleted { get; }

لكن إذا كانت أسباب الفشل متعدّدة، يضعف bool.

bool TryCreateUser(CreateUserCommand command);

هذه الطريقة لا تُظهِر السبب عند الفشل.

يمكن التعويض بمعامل out.

bool TryCreateUser(CreateUserCommand command, out User user, out string errorCode);

لكنّه يزداد تعقيداً تدريجياً.

في هذه الحالة، جعلها نوع نتيجة أكثر قابلية للقراءة.

CreateUserResult CreateUser(CreateUserCommand command);

تستطيع جهة الاستدعاء أيضاً التعامل مع نوع الفشل كنوع (type)، لا مجرّد النجاح/الفشل.

return result.Match(
    created => Ok(created.User),
    duplicate => Conflict(),
    weak => BadRequest(),
    failure => StatusCode(500));

ومعيار الحكم كالتالي.

اختيار حقيقي بين خيارين دون حاجة لمعلومات إضافية
  => bool

اختيار بين خيارين لكن مع حاجة لقيمة النجاح أو سبب الفشل
  => Result

ثلاثة خيارات فأكثر، أو بيانات مختلفة لكلّ حالة
  => ADT

25. الفرق بين الوراثة وADT

عند إنشاء نوع على طراز ADT في C#، يبدو شكلاً قريباً من الوراثة العادية.

public abstract class PaymentResult
{
}

public sealed class Succeeded : PaymentResult
{
}

public sealed class Rejected : PaymentResult
{
}

لكنّ الهدف مختلف نوعاً ما.

غالباً ما تُستخدَم الوراثة الاعتيادية في البرمجة الكائنية التوجّه لاستبدال السلوك.

public abstract class Shape
{
    public abstract double Area();
}

public sealed class Circle : Shape
{
    public override double Area() => ...;
}

أمّا الوراثة على طراز ADT، فتُستخدَم للتعبير عن «الأشكال الممكنة للبيانات».

public abstract class PaymentResult
{
    public sealed class Succeeded : PaymentResult
    {
        public string ReceiptNo { get; }
    }

    public sealed class Rejected : PaymentResult
    {
        public string Reason { get; }
    }
}

الأمر ليس مسألة أيّهما صحيح.

إذا أردتَ وضع المعالجة داخل كلّ حالة، فتعدّد الأشكال (polymorphism) الاعتيادي مناسب.

public abstract class Notification
{
    public abstract void Send();
}

إذا أردتَ التفرّع في جهة الاستدعاء وأنت ترى جميع الحالات، فـADT + مطابقة الأنماط / Match مناسب.

return notification.Match(
    email => SendEmail(email),
    sms => SendSms(sms),
    push => SendPush(push));

في تطبيقات الأعمال، من السهل فهم هذا التوزيع: ADT للقيم المرجَعة والحالة، والواجهة (interface) لاستبدال السلوك.

26. عدم تشتيت مطابقة الأنماط أكثر من اللازم

عند البدء باستخدام ADT، تجد نفسك تريد كتابة switch أوMatch في أماكن متفرّقة.

لكن إذا تناثر التفرّع نفسه في مواضع متعدّدة، يزداد عدد المواضع التي يجب تعديلها عند إضافة حالة.

لنفترض أنّك تستخدم switch على PaymentResult في أماكن مختلفة.

تحويل استجابة الـAPI
إخراج السجلّ (log)
توليد رسالة الشاشة
تسجيل المقاييس (metrics)
توليد سجلّ التدقيق (audit log)

عند إضافة حالة، يجب تعديل جميع عبارات switch.

هذا قد لا يمكن تجنّبه أحياناً، لكن تجميع مسؤولية التفرّع قدر الإمكان يجعل الصيانة أسهل.

public static class PaymentResultMapper
{
    public static PaymentResultDto ToDto(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }

    public static string ToLogMessage(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }
}

وفي بعض الأحيان يكون من الأفضل جعل الحالة نفسها تحمل المعالجة دون تفرّع.

public abstract class PaymentResult
{
    public abstract bool IsSuccess { get; }
}

لكن إذا حُمِّلت الحالة معالجة أكثر من اللازم، يبدأ نوع النطاق بمعرفة اعتبارات الـAPI أو واجهة المستخدم.

وغالباً ما يكون من الأفضل عدم إدخال معالجات كهذه مباشرةً في نوع النطاق.

  • التحويل إلى رمز حالة HTTP
  • التحويل إلى DTO لـJSON
  • رسالة العرض على الشاشة
  • صيغة السجلّ (log)
  • التمثيل الخاصّ بـOpenAPI

نوع النطاق يعبِّر عن المعنى التجاري. والتحويل عند الحدود يوضَع في Mapper.

مع مراعاة هذا الفصل، تصبح صيانة ADT أسهل على المدى الطويل.

27. طريقة التسمية

الاسم مهمّ في الأنواع على طراز ADT.

الاكتفاء بأسماء عامّة مثل Result وError وResponse يُضعِف المعنى.

التسميات الشائعة الاستخدام هي كالتالي.

CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState

اجعل أسماء الحالات قريبة من المصطلحات التجارية.

Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked

بالاكتفاء بـError1 وError2 وFailed، يصعب على جهة الاستدعاء فهم المعنى.

كما اجعل البيانات التي تحملها الحالة من أنواع تجارية قدر الإمكان.

public sealed class CreditLimitExceeded : SubmitOrderResult
{
    public Money Limit { get; }
    public Money RequestedAmount { get; }
}

يعمل الأمر حتى مع decimal أوstring كما هما، لكنّ الجمع مع كائنات قيمة مثل Money وEmail وUserId وOrderId يزيد وضوح النيّة.

يتوافق ADT مع كائنات القيمة (value objects) بشكل جيّد.

كائن القيمة
  يعبِّر عن معنى وقيود قيمة واحدة

ADT
  يعبِّر عن أشكال متعدّدة ممكنة

بالجمع بين هذين النوعين، يسهل حصر قواعد العمل داخل النوع.

28. الانتباه إلى ترقيم الإصدارات

بما أنّ ADT يوضّح مجموعة الحالات صراحةً، فإضافة حالة تؤثّر في جهة الاستدعاء.

وهذا ميزة ونقطة يجب الانتباه إليها في آن واحد.

في الشيفرة الداخلية، ظهور خطأ ترجمة عند إضافة حالة أمر مرحَّب به. لأنّه يتيح اكتشاف نقص المعالجة.

ومن ناحية أخرى، في الأنواع المقدَّمة للخارج كحزمة NuGet أو واجهة عامة، قد تحمل إضافة حالة معنىً قريباً من التغيير المدمِّر.

لنفترض أنّ مستخدم المكتبة كتب معالجة جميع الحالات كالتالي.

var text = result.Match(
    success => ...,
    validationError => ...,
    permissionDenied => ...);

إذا أضافت المكتبة حالة RateLimited وغيّرت توقيع Match تبعاً لذلك، تصبح شيفرة المستخدم خطأ ترجمة.

هذا آمن، لكنّه يؤثّر من ناحية توافق الواجهة العامة.

لذلك، في المكتبات العامة، فكِّر كالتالي.

  • إذا سُمِح بإضافة حالة، ارفع رقم الإصدار وعامِلها كتغيير مدمِّر
  • إذا أردتَ السماح للمستخدمين الخارجيين بمعالجة من نوع default، اختر تصميماً آخر بدلاً من ADT مغلق
  • كن صارماً في النطاق الداخلي، واعتمد DTO وعقداً مرقَّماً بإصدار في الـAPI الخارجي

داخل تطبيق الأعمال، ظهور خطأ ترجمة عند إضافة حالة أمر مرغوب فيه.

أمّا في الـAPI العامة، فيجب التفكير في تصميم التوافق أيضاً.

29. حول الأداء

قد يزيد التصميم على طراز ADT عدد الكائنات (objects) من أجل قوّة التعبير.

عند استخدام تسلسل class في .NET Framework، يُنشَأ كائن لكلّ حالة.

return PaymentResult.Success(receiptNo);

هذا غالباً لا يمثّل مشكلة كبيرة في تطبيقات الأعمال الاعتيادية.

لكن انتبه في مواضع مثل التالية.

  • معالجة منخفضة المستوى تُستدعى بتكرار عالٍ
  • معالجة تدفّق (stream) تتعامل مع كمّ هائل من الأحداث
  • الألعاب أو المعالجة الفورية (real-time)
  • المعالجة التي تريد تقليل التخصيص (allocation) بشكل كبير
  • معالجة تخزّن كمّاً هائلاً من ADT في مجموعة (collection) ضخمة

عندما يكون الأداء مهمّاً، هناك عدّة خيارات.

  • استخدام نوع Result مبني على struct
  • النظر في struct discriminated union في F#
  • كبح التخصيص (allocation) عبر Source Generator
  • استخدام enum + حقل مخصَّص في المسار الساخن (hot path)، والتحويل إلى ADT عند الحدود
  • التحسين بعد القياس

لا حاجة للإفراط في التحسين منذ البداية.

في كثير من الأنظمة التجارية، تفوق قيمة وضوح التصميم الناتج عن ADT كلفةَ إنشاء الكائنات الطفيفة.

لكن في المواضع ذات متطلّبات أداء صارمة، ينبغي التفكير في التصميم والقياس معاً.

30. مثال على إعادة هيكلة شيفرة قائمة

أخيراً، لنرَ تدفّق تعديل شيفرة قائمة شائعة بأسلوب ADT.

الشيفرة الأصلية كالتالي.

public bool TryReserveStock(string sku, int quantity, out string errorCode)
{
    errorCode = null;

    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        errorCode = "SKU_NOT_FOUND";
        return false;
    }

    if (stock.Available < quantity)
    {
        errorCode = "OUT_OF_STOCK";
        return false;
    }

    stock.Reserve(quantity);
    return true;
}

في هذه الشيفرة، يُعبَّر عن سبب الفشل بـstring. ويجب على جهة الاستدعاء مقارنة السلاسل النصّية.

string errorCode;
if (!service.TryReserveStock(sku, quantity, out errorCode))
{
    if (errorCode == "SKU_NOT_FOUND")
    {
        ...
    }
    else if (errorCode == "OUT_OF_STOCK")
    {
        ...
    }
}

لنحوّل هذا إلى نوع نتيجة.

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class SkuNotFound : ReserveStockResult
    {
        internal SkuNotFound(Sku sku)
        {
            Sku = sku;
        }

        public Sku Sku { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public static ReserveStockResult Success(ReservationId reservationId)
        => new Reserved(reservationId);

    public static ReserveStockResult NotFound(Sku sku)
        => new SkuNotFound(sku);

    public static ReserveStockResult NotEnough(Sku sku, int requested, int available)
        => new OutOfStock(sku, requested, available);

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<SkuNotFound, T> skuNotFound,
        Func<OutOfStock, T> outOfStock)
    {
        var r = this as Reserved;
        if (r != null) return reserved(r);

        var n = this as SkuNotFound;
        if (n != null) return skuNotFound(n);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("نتيجة حجز مخزون غير معروفة.") ;
    }
}

تصبح طريقة الخدمة كالتالي.

public ReserveStockResult ReserveStock(Sku sku, int quantity)
{
    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        return ReserveStockResult.NotFound(sku);
    }

    if (stock.Available < quantity)
    {
        return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
    }

    var reservationId = stock.Reserve(quantity);
    return ReserveStockResult.Success(reservationId);
}

تستطيع جهة الاستدعاء التخلّي عن مقارنة السلاسل النصّية.

var result = service.ReserveStock(sku, quantity);

return result.Match(
    reserved => Ok(new { reserved.ReservationId }),
    notFound => NotFound(new { sku = notFound.Sku.Value }),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

النقطة الجوهرية في إعادة الهيكلة هذه هي إمكانية نقل المعنى الداخلي إلى النوع دون تغيير السلوك الخارجي.

أوّلاً، قوِّ قيمة الإرجاع. ثمّ، انقل جهة الاستدعاء نحو Match. وأخيراً، قلِّل رموز الخطأ النصّية والخصائص المساعدة القابلة لـnull تدريجياً.

بهذا الترتيب، يمكن التبنّي تدريجياً حتى في الأنظمة القائمة.

31. قائمة تحقّق عند التبنّي

عند إنشاء نوع على طراز ADT، تحقّق من النقاط التالية.

هل يعبِّر هذا النوع عن «واحد فقط من بين عدّة»؟
هل مجموعة الحالات مغلقة على المستوى التجاري؟
هل البيانات اللازمة مختلفة لكلّ حالة؟
هل ينهار المعنى مع bool / enum / null / رمز خطأ نصّي؟
هل تريد أن تعي جهة الاستدعاء معالجة جميع الحالات؟
هل يؤثّر ذلك في توافق الواجهة العامة؟
هل توجد سياسة تحويل مع JSON / قاعدة البيانات / DTO الشاشة؟
إذا كان الاستخدام يشمل .NET Framework، هل يكفي class عادي؟
إذا كان الاستهداف .NET الحالي فقط، هل تستحقّ استخدام record أو Source Generator؟

يمكن اختيار سياسة التنفيذ كالتالي.

مشروع F#
  استخدام الاتحاد المُميَّز في F#

C# في .NET Framework
  abstract class + باني خاص + أصناف sealed متداخلة + Match

C# في .NET 5 فما بعده
  abstract record + حالات sealed record + مطابقة الأنماط

قيمة إرجاع محلّية
  مكتبة مثل OneOf

تقليل الشيفرة النمطية في .NET الحالي
  مكتبات مبنيّة على Source Generator

تحقّق مستقبلي
  معاينة union في C# 15

أياً كانت الطريقة المختارة، فالهدف واحد.

حماية القاعدة بالنوع بدلاً من حمايتها بالتعليقات.

وهذا هو أعظم معنى لاستخدام ADT.

32. الخلاصة

الأنواع الجبرية للبيانات ليست حكراً على اللغات الوظيفية (functional languages).

حتى في C# على .NET Framework، يمكن استخدامها عملياً بشكل كافٍ عبر الأصناف المجرَّدة والأصناف sealed. وفي C# على .NET الحالي، يمكن كتابتها بإيجاز أكبر عبر record ومطابقة الأنماط. وفي F#، يمكن استخدامها كميزة لغوية بذاتها عبر الاتحاد المُميَّز. وباستخدام المكتبات، يمكن التعامل بسهولة مع OneOf أوResult حتى في C#.

المهمّ ليس الصياغة، بل فكرة التصميم.

أعِد النظر فيما كنتَ تعبِّر عنه بـbool أوnull أوenum + خصائص قابلة لـnull أوstring ErrorCode، واطرح على نفسك هذه الأسئلة.

أيّ حالة من بين الحالات تمثّلها هذه القيمة؟
ما البيانات اللازمة لكلّ حالة؟
ما البيانات التي يجب ألّا توجد إلا في تلك الحالة؟
ما الذي تريد أن تجبر جهة الاستدعاء على معالجته دائماً؟

عند بناء النوع إجابةً على هذه الأسئلة، تقلّ الحالات غير الصحيحة، ويتحسّن وضوح التفرّع، وتبقى المصطلحات التجارية في الشيفرة.

في الأنظمة القائمة، يُنصَح بالبدء بقيم الإرجاع أوّلاً.

جرِّب استبدال المواضع التي يتزايد فيها TryXxx وnull وErrorCode وتفرّع العمل عبر الاستثناءات، بنوع نتيجة مخصَّص.

هذا وحده يغيّر كثيراً من مقروئية الشيفرة وسلامتها.

المراجع

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

ما هي الأنواع الجبرية للبيانات (Algebraic Data Types/ADT)؟
الأنواع الجبرية للبيانات هي مزيج من النوع الضربي (product type) (نوع يحمل A وB معاً) والنوع الاتحادي (sum type) (نوع يكون إمّا A أو B). ومن الناحية العملية، هي طريقة تفكير تُعبِّر عن «أنّ هذه القيمة تأخذ أشكالاً محدَّدة سلفاً» عبر النوع نفسه، لا عبر التعليقات أو قواعد التسمية. والأكثر استخداماً بشكل خاص هو النوع الاتحادي؛ فمثلاً يُعبَّر عن نتيجة الدفع بأنّها «إحدى الحالات التالية فقط: Succeeded(receiptNo) أو Rejected(reason) أو NetworkFailure(message)»، بحيث يستحيل أصلاً إنشاء حالة غير صحيحة.
كيف يمكن تنفيذ اتحاد مُميَّز (discriminated union / نوع اتحادي) في C#؟
أسهل طريقة للتبنّي في الأنظمة القائمة، بما فيها .NET Framework، هي نمط: صنف أساس مجرَّد (abstract) له باني (constructor) خاص (private) + أصناف sealed متداخلة (nested) + طريقة Match. فبما أنّ الأصناف المتداخلة وحدها هي التي يمكنها وراثة الصنف الأساس، يمكن إنشاء مجموعة حالات مغلقة. وإن كان الاستهداف .NET 5 فما بعده، يمكن كتابة الأمر بإيجاز أكبر عبر تسلسل abstract record وsealed record. أمّا للقيم المُرجَعة المحلّية، فمكتبة مثل OneOf خيار متاح، وفي F# يمكن استخدام الاتحاد المُميَّز بشكل طبيعي كميزة لغوية.
متى ينبغي استخدام الأنواع الجبرية للبيانات بدلاً من enum أو bool؟
إذا كان يكفي معرفة الحالة فقط، فـenum كافٍ؛ وإذا كان الاختيار فعلاً بين خيارين اثنين دون حاجة لمعلومات إضافية، فـbool كافٍ. أمّا إذا كانت البيانات التي تحملها كلّ حالة مختلفة (مثل receiptNo عند النجاح وshortage عند نقص الرصيد)، فـADT هو الأنسب. وإذا بدأ تركيب enum مع مجموعة من الخصائص القابلة لـnull بالتزايد، أو بدأتَ بمقارنة أسباب الفشل عبر رموز خطأ نصّية (string)، فهذه إشارة إلى ضرورة التحوّل إلى ADT. لكن إذا كان التصميم يسمح بزيادة الحالات من الخارج (كالإضافات/plugins)، فالواجهة (interface) أنسب من ADT مغلق.
هل ينبغي التعبير عن الفشل التجاري بالاستثناءات أم بنوع Result؟
من الناحية العملية، يُفضَّل توزيع الأدوار التالي: الفشل المتوقَّع الذي ينبغي أن تتعامل معه جهة الاستدعاء كتفرّع اعتيادي (كنقص المخزون، وتكرار البريد الإلكتروني، وخطأ كلمة المرور) يُعاد عبر Result/ADT، أمّا الحالات الشاذّة غير المتوقَّعة التي لا يمكن التعافي منها في المعالجة الاعتيادية (كانقطاع اتّصال قاعدة البيانات، أو تلف ملفّ الإعدادات) فتُجعَل استثناءات (exceptions). وإذا حُوِّل حتى التفرّع الذي يحدث كثيراً على المستوى التجاري إلى استثناء، يتحوّل try-catch إلى بديل عن تفرّع العمل، فيسوء وضوح المعالجة. وهذا التوزيع وحده يحسِّن كثيراً وضوح طبقة خدمات التطبيق وطبقة الواجهة (API).

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

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

روابط عامة

العودة إلى المدونة