عنوان:

‫درک توسعه مبتنی بر استثناء (Exception-Driven Development): یک ضدالگو در برنامه‌نویسی


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۳/۰۹ ۱۱:۱۳
آدرس: www.dntips.ir
تلاش برای نوشتن کدی تمیز، قابل نگهداری و با کارایی بالا همواره از اهداف اصلی برنامه‌نویسان بوده است. با این حال، گاهی استفاده نادرست از برخی سازوکارهای زبان برنامه‌نویسی می‌تواند منجر به بروز ضدالگوهایی (Anti-Patterns) شود که کیفیت کد را به شدت تحت تأثیر قرار می‌دهند. یکی از این ضدالگوهای رایج، «توسعه مبتنی بر استثناء» (Exception-Driven Development یا EDD) است. این رویکرد، استثناها (Exceptions) را از حالت هشدارهای نادر به چراغ‌های راهنمایی روزمره تبدیل می‌کند که منجر به کندی کد، پیچیدگی منطق و دور شدن از نرم‌افزاری پاک و قابل نگهداری می‌شود. در این مقاله، به بررسی عمیق این ضدالگو، دلایل مضر بودن آن و ارائه راهکارهای بهتر با تمرکز بر توسعه‌دهندگان .NET خواهیم پرداخت.

توسعه مبتنی بر استثناء (Exception-Driven Development) چیست؟
توسعه مبتنی بر استثناء (EDD) یک رویکرد برنامه‌نویسی است که در آن از استثناها برای کنترل جریان عادی برنامه استفاده می‌شود، به جای آنکه برای شرایط واقعاً استثنایی و غیرمنتظره رزرو شوند. در این پارادایم، استثناها به یک ساختار کنترلی شبیه به دستورات if یا حلقه‌ها تبدیل می‌شوند، نه اینکه به عنوان شبکه‌های ایمنی برای شرایط واقعاً استثنایی عمل کنند که به ندرت باید رخ دهند. ویژگی اصلی EDD، طراحی عامدانه کدی است که انتظار دارد استثناها در طول عملیات عادی پرتاب شوند. به جای بررسی پیش‌شرط‌ها یا استفاده از متدهای تخصصی برای تعیین موفقیت یک عملیات، توسعه‌دهندگان با این رویکرد به سادگی عملیات را انجام داده و هرگونه استثناء ناشی از آن را دریافت (catch) می‌کنند.

پیشینه تاریخی استثناها
مکانیسم‌های استثناء در زبان‌های برنامه‌نویسی برای مدیریت شرایط غیرمنتظره و استثنایی معرفی شدند که به ندرت در طول اجرای عادی برنامه رخ می‌دهند. زبان‌هایی مانند C++ و بعدها #Java، C و سایرین، مدیریت استثناء را برای جداسازی کد مدیریت خطا از منطق اصلی برنامه پیاده‌سازی کردند تا کد واضح‌تر و قابل نگهداری‌تر شود. با این حال، همانند بسیاری از ویژگی‌های برنامه‌نویسی، استثناها به روش‌هایی مورد استفاده قرار گرفتند که طراحان آن‌ها در نظر نداشتند. برخی توسعه‌دهندگان شروع به استفاده از استثناها برای جریان کنترل عادی کردند که منجر به چیزی شد که اکنون به عنوان توسعه مبتنی بر استثناء می‌شناسیم.

چرا توسعه مبتنی بر استثناء مضر تلقی می‌شود؟
EDD به دلایل قانع‌کننده‌ای که بر کیفیت کد، عملکرد و قابلیت نگهداری تأثیر می‌گذارند، به طور گسترده یک عمل نامناسب در نظر گرفته می‌شود.

پیامدهای عملکردی
استثناها برای شرایط استثنایی طراحی شده‌اند و بنابراین پیاده‌سازی آن‌ها اطلاعات تشخیصی را بر عملکرد اولویت می‌دهد. هنگامی که یک استثناء پرتاب می‌شود، سیستم زمان اجرا (runtime system) باید چندین عملیات پرهزینه انجام دهد: گرفتن پشته فراخوانی فعلی (call stack)، جستجو برای یک کنترل‌کننده استثناء (exception handler) مناسب، باز کردن پشته فراخوانی (unwind the call stack) و انتقال کنترل به کنترل‌کننده. این فرآیند به طور قابل توجهی گران‌تر از مکانیسم‌های جریان کنترل عادی است. در C#، پرتاب یک استثناء می‌تواند صدها یا حتی هزاران بار کندتر از استفاده از منطق شرطی باشد.

کاهش خوانایی کد
کدی که برای جریان کنترل به استثناها متکی است، معمولاً خواندن و درک آن دشوارتر است. جریان واقعی اجرا مبهم می‌شود و توسعه‌دهندگان را ملزم می‌کند تا به جای دنبال کردن یک روایت خطی و واضح، مسیرهای بالقوه استثناء را به صورت ذهنی ردیابی کنند.

پیچیدگی اشکال‌زدایی (Debugging)
هنگامی که استثناها به طور مکرر به عنوان بخشی از عملیات عادی پرتاب می‌شوند، تشخیص بین استثناهای مورد انتظار و آنهایی که واقعاً مشکل‌ساز هستند، دشوار می‌شود. این امر می‌تواند تلاش‌های اشکال‌زدایی را به طور قابل توجهی پیچیده کند، زیرا ابزارها و لاگ‌ها با "نویز" ناشی از استثناهای مورد انتظار پر می‌شوند.

پنهان کردن خطاهای منطقی
توسعه مبتنی بر استثناء می‌تواند با تلقی خطاهای منطقی به عنوان جریان عادی برنامه، آن‌ها را پنهان کند. این کار مرز بین شرایط مورد انتظار و باگ‌های واقعی را محو می‌کند و شناسایی و رفع مشکلات واقعی در کد را دشوارتر می‌سازد.

چالش‌های تست‌نویسی
کدی که برای جریان کنترل به استثناها متکی است، معمولاً تست‌نویسی آن دشوارتر است. تست‌های واحد (Unit tests) باید استثناهای مورد انتظار را در نظر بگیرند که طراحی تست را پیچیده کرده و می‌تواند معیارهای پوشش کد (code coverage) را کم‌معناتر کند.

شناسایی توسعه مبتنی بر استثناء
توسعه مبتنی بر استثناء را می‌توان با چندین نشانه در کد شناسایی کرد:
  • استفاده مکرر از بلوک‌های try-catch برای عملیات روتین.
  • بلوک‌های catch خالی یا بلوک‌هایی که استثناها را بدون مدیریت معنادار می‌بلعند (swallow exceptions).
  • استفاده از استثناها برای اعتبارسنجی یا شرایط خطای مورد انتظار.
  • جریان کنترلی که به پرتاب شدن استثناهای خاص بستگی دارد.
  • متدها یا APIهایی که برای پرتاب استثناها به عنوان بخشی از عملیات عادی خود طراحی شده‌اند.

ابزارهایی مانند تحلیل‌گرهای کد ایستا (Static code analyzers) مانند SonarQube و ReSharper می‌توانند برخی از الگوهای استفاده نادرست از استثناء را شناسایی کنند. همچنین، پروفایلرهای عملکرد (Performance profilers) مانند dotTrace یا Visual Studio Profiler می‌توانند مشخص کنند که استثناها در کجا به طور مکرر در طول عملیات عادی پرتاب می‌شوند. بازبینی کد (Code reviews) منظم توسط همکاران با تأکید بر مدیریت استثناء نیز می‌تواند به شناسایی EDD کمک کند.

ضدالگوهای رایج EDD در C#
بیایید برخی از الگوهای رایج که در آن‌ها توسعه‌دهندگان به طور سهوی یا عمدی از EDD در کد C# استفاده می‌کنند را بررسی کنیم.

ضدالگو ۱: استفاده از استثناها برای اعتبارسنجی
یکی از رایج‌ترین نمونه‌های EDD استفاده از استثناها برای اعتبارسنجی ورودی است. به عنوان مثال، در کد زیر، به جای بررسی صریح اینکه آیا username تهی (null) است، کد به پرتاب شدن NullReferenceException هنگام دسترسی به خاصیت Length متکی است:
public void ProcessUser(string username)
{
    try
    {
        // Directly try to process without validation
        if (username.Length < 3) // Potential NullReferenceException if username is null
        {
            Console.WriteLine("Username too short");
        }
        // Further processing...
    }
    catch (NullReferenceException)
    {
        Console.WriteLine("Username cannot be null");
    }
}
این رویکرد درک کد را دشوارتر و ناکارآمدتر می‌کند. یک رویکرد بهتر، بررسی صریح پیش‌شرط‌ها است:
public void ProcessUser(string username)
{
    if (username == null)
    {
        Console.WriteLine("Username cannot be null");
        return;
    }
    
    if (username.Length < 3)
    {
        Console.WriteLine("Username too short");
    }
    // Further processing...
}

ضدالگو ۲: عملیات فایل بدون بررسی‌های اولیه
سناریوی رایج دیگر، انجام عملیات فایل بدون بررسی اولیه وجود فایل است:
public string ReadFileContents(string filePath)
{
    try
    {
        return File.ReadAllText(filePath); // Potential FileNotFoundException
    }
    catch (FileNotFoundException)
    {
        return "File not found";
    }
}
کد فوق به استثناء برای تعیین وجود فایل متکی است. رویکرد مستقیم‌تر به شکل زیر است:
public string ReadFileContents(string filePath)
{
    if (!File.Exists(filePath))
    {
        return "File not found";
    }
    
    return File.ReadAllText(filePath);
}
با این حال، شایان ذکر است که حتی این نسخه بهبود یافته نیز دارای یک وضعیت رقابتی (race condition) بین بررسی و عملیات خواندن است. یک راه‌حل واقعاً مقاوم، استثناء بالقوه‌ای را که ممکن است در صورت حذف فایل بین بررسی و خواندن رخ دهد، مدیریت می‌کند.

ضدالگو ۳: جستجو در دیکشنری (Dictionary Lookups)
استفاده از استثناها برای جستجو در دیکشنری نیز یک الگوی رایج است:
public string GetValue(Dictionary<string, string> dictionary, string key)
{
    try
    {
        return dictionary[key]; // Potential KeyNotFoundException
    }
    catch (KeyNotFoundException)
    {
        return "Key not found";
    }
}
دیکشنری‌های C# یک متد خاص برای جلوگیری از این رویکرد مبتنی بر استثناء ارائه می‌دهند:
public string GetValue(Dictionary<string, string> dictionary, string key)
{
    if (dictionary.TryGetValue(key, out string value))
    {
        return value;
    }
    return "Key not found";
}

ضدالگو ۴: تبدیل نوع (Type Conversions)
تبدیل نوع مبتنی بر استثناء نیز رایج است:
public int ConvertToInt(string input)
{
    try
    {
        return int.Parse(input); // Potential FormatException
    }
    catch (FormatException)
    {
        return 0; // Default value on failure
    }
}
یک رویکرد کارآمدتر از الگوی TryParse استفاده می‌کند:
public int ConvertToInt(string input)
{
    if (int.TryParse(input, out int result))
    {
        return result;
    }
    return 0; // Default value on failure
}

چه زمانی استفاده از استثناها مناسب است؟
مهم است که بین EDD و استفاده صحیح از استثناها تمایز قائل شویم. استثناها هنگامی که به درستی استفاده شوند، ابزاری ارزشمند باقی می‌مانند. آن‌ها برای شرایط واقعاً استثنایی که نباید در طول اجرای عادی برنامه رخ دهند، مناسب هستند، مانند: خرابی شبکه، مشکلات اتصال به پایگاه داده، مشکلات مجوز سیستم فایل، کمبود حافظه و خطاهای برنامه‌نویسی که نشان‌دهنده باگ هستند (مانند اندیس خارج از محدوده آرایه).

یک تمایز کلیدی بین موارد استثنایی و خرابی‌های مورد انتظار وجود دارد:
  • خرابی مورد انتظار (Expected failure): شرایطی که معمولاً در طول عملیات عادی رخ می‌دهد، مانند وارد کردن داده نامعتبر توسط کاربر یا عدم وجود یک فایل.
  • شرایط استثنایی (Exceptional condition): یک وضعیت نادر یا غیرمنتظره که نشان می‌دهد مشکلی جدی رخ داده است.

گاهی اوقات شما مجبور به کار با APIهایی هستید که به عنوان بخشی از عملیات عادی خود استثناء پرتاب می‌کنند. در این موارد، گرفتن این استثناها مناسب است، اگرچه ممکن است بخواهید چنین APIهایی را در APIهای خود که از شیوه‌های بهتری پیروی می‌کنند، بپوشانید. همچنین، هنگامی که کد مشتری (client code) قرارداد یک متد را نقض می‌کند (مانند ارسال null زمانی که متد null را نمی‌پذیرد)، پرتاب یک استثناء مانند ArgumentNullException مناسب است.

به عنوان مثال، در کد زیر، اعتبارسنجی اولیه انجام می‌شود و سپس عملیات نوشتن فایل در یک بلوک try-catch قرار می‌گیرد، زیرا این عملیات همچنان ممکن است به دلایل استثنایی (مانند پر بودن دیسک یا مشکلات مجوز) با شکست مواجه شود:
public void SaveDocument(Document document, string path)
{
    if (document == null)
        throw new ArgumentNullException(nameof(document)); // Contract violation
    if (string.IsNullOrEmpty(path))
        throw new ArgumentException("Path cannot be empty", nameof(path)); // Contract violation

    // Normal validation completed, now we can proceed
    try
    {
        // This could still fail for exceptional reasons (disk full, permissions)
        File.WriteAllText(path, document.Content);
    }
    catch (IOException ex)
    {
        // Handle truly exceptional condition
        LogError($"Failed to save document: {ex.Message}");
        throw new DocumentSaveException("Could not save the document", ex);
    }
}

جایگزین‌های بهتر برای توسعه مبتنی بر استثناء
چندین الگوی طراحی و تکنیک برنامه‌نویسی، جایگزین‌های بهتری برای EDD ارائه می‌دهند.

الگوی TryPattern در C#
C# الگوی "Try" را رایج کرده است که روشی واضح و کارآمد برای مدیریت عملیاتی که ممکن است با شکست مواجه شوند، ارائه می‌دهد. این الگو پتانسیل شکست را در امضای متد صریح می‌کند و از سربار عملکردی استثناها جلوگیری می‌کند.
// Example of a TryPattern method signature
public bool TryGetUserDetails(string userId, out UserDetails details)
{
    // Implementation that returns true for success, false for failure
    // and populates 'details' when successful
    details = null; // Placeholder for actual implementation
    // ... logic to get user details ...
    if (/* condition for success */ true) {
        // details = ...;
        return true;
    }
    return false;
}

// Usage:
if (TryGetUserDetails("user123", out var userDetails))
{
    // Use userDetails
}
else
{
    // Handle failure case
}

الگوی Result
الگوی Result (نتیجه) روشی ساختاریافته برای بازگرداندن یا یک مقدار موفقیت‌آمیز یا اطلاعات خطا ارائه می‌دهد.
public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public string Error { get; }
    
    private Result(bool isSuccess, T value, string error)
    {
        IsSuccess = isSuccess; Value = value; Error = error;
    }
    
    public static Result<T> Success(T value) => new Result<T>(true, value, null);
    public static Result<T> Failure(string error) => new Result<T>(false, default, error);
}

// Usage example:
public Result<User> GetUser(string id) // Assuming User class and _repository exist
{
    if (string.IsNullOrEmpty(id))
    {
        return Result<User>.Failure("ID cannot be empty");
    }
    // User user = _repository.FindById(id); // Placeholder
    User user = null; // Placeholder for actual repository call
    if (user == null)
    {
        return Result<User>.Failure("User not found");
    }
    return Result<User>.Success(user);
}

// Client code:
// var result = GetUser("123");
// if (result.IsSuccess)
// {
//     Console.WriteLine($"Found user: {result.Value.Name}");
// }
// else
// {
//     Console.WriteLine($"Error: {result.Error}");
// }

انواع Option/Maybe
انواع Option یا Maybe به صراحت امکان عدم وجود یک مقدار را نشان می‌دهند. (این انواع معمولاً از طریق کتابخانه‌های شخص ثالث یا پیاده‌سازی سفارشی در دسترس هستند.)

گارد کلازها (Guard Clauses)
گارد کلازها بازگشت‌های زودهنگام برای شرایط نامعتبر ارائه می‌دهند، کد را خواناتر کرده و نیاز به شرطی‌های تودرتو را از بین می‌برند.
public void ProcessOrder(Order order) // Assuming Order class exists
{
    // Guard clauses
    if (order == null)
        throw new ArgumentNullException(nameof(order)); // Appropriate exception for contract violation
    // if (order.Items.Count == 0) // Assuming Items property exists
    //     return; 
    // if (!order.IsValid) // Assuming IsValid property exists
    //     return;
    // Main processing logic follows
    // ...
}

بازسازی (Refactoring) کد مبتنی بر استثناء
بیایید بررسی کنیم که چگونه کد مبتنی بر استثناء را برای پیروی از شیوه‌های بهتر بازسازی کنیم.

مثال ۱: بازسازی منطق اعتبارسنجی
قبل: کد از decimal.Parse در یک بلوک try-catch برای تبدیل رشته به decimal استفاده می‌کند و در صورت بروز FormatException مقدار ۰ را بازمی‌گرداند.
public decimal CalculateTax(string incomeText)
{
    try
    {
        decimal income = decimal.Parse(incomeText);
        return income * 0.2m;
    }
    catch (FormatException)
    {
        return 0;
    }
}
بعد: کد از decimal.TryParse برای تبدیل امن استفاده می‌کند و در صورت موفقیت، محاسبه را انجام می‌دهد.
public decimal CalculateTax(string incomeText)
{
    if (decimal.TryParse(incomeText, out decimal income))
    {
        return income * 0.2m;
    }
    return 0;
}

مثال ۲: بازسازی دسترسی به منابع
قبل: کد سعی می‌کند مشتری را از مخزن (repository) دریافت کند و در صورت بروز CustomerNotFoundException، یک مشتری پیش‌فرض با نام "Unknown" بازمی‌گرداند.
// Assuming _customerRepository and Customer class exist
public Customer GetCustomer(int id)
{
    try
    {
        return _customerRepository.Get(id); // Potential CustomerNotFoundException
    }
    catch (CustomerNotFoundException)
    {
        return new Customer { Name = "Unknown" };
    }
}
بعد (با بررسی وجود): ابتدا بررسی می‌شود که آیا مشتری وجود دارد و سپس اقدام به دریافت آن می‌شود.
// Assuming _customerRepository.Exists(id) and _customerRepository.Get(id) methods exist
public Customer GetCustomer(int id)
{
    if (_customerRepository.Exists(id))
    {
        return _customerRepository.Get(id);
    }
    return new Customer { Name = "Unknown" };
}
بعد (با استفاده از الگوی Result): این رویکرد اطلاعات دقیق‌تری در مورد موفقیت یا شکست عملیات ارائه می‌دهد و همچنین می‌تواند خطاهای غیرمنتظره دیگر را نیز مدیریت کند.
// Assuming Result<T>, Customer class, and _customerRepository exist
public Result<Customer> GetCustomer(int id)
{
    if (!_customerRepository.Exists(id)) // Assumes Exists method
    {
        return Result<Customer>.Failure($"Customer with id {id} not found");
    }
    
    try
    {
        var customer = _customerRepository.Get(id); // Assumes Get method
        return Result<Customer>.Success(customer);
    }
    catch (Exception ex) // Catching broader exceptions for unexpected issues during retrieval
    {
        // Log the exception (e.g., LogError(ex.Message);)
        return Result<Customer>.Failure($"Error retrieving customer: {ex.Message}");
    }
}

تأثیر توسعه مبتنی بر استثناء بر کیفیت نرم‌افزار
استفاده از EDD چندین تأثیر منفی بر کیفیت کلی نرم‌افزار دارد:
  • کاهش قابلیت نگهداری (Maintainability): درک و نگهداری کدی که از استثناها برای جریان کنترل استفاده می‌کند، دشوارتر است. توسعه‌دهندگان جدید ممکن است در دنبال کردن مسیر اجرا دچار مشکل شوند.
  • کاهش عملکرد (Performance): همانطور که قبلاً ذکر شد، استثناها به طور قابل توجهی گران‌تر از جریان کنترل عادی هستند و می‌توانند منجر به کندی محسوس در برنامه‌های حساس به عملکرد شوند.
  • ابهام در قصد (Obscured Intent): کد مبتنی بر استثناء اغلب قصد اصلی توسعه‌دهنده را پنهان می‌کند.
  • کد شکننده (Brittle Code): EDD اغلب منجر به کدی شکننده می‌شود که با تغییر پیاده‌سازی‌های زیربنایی به روش‌های غیرمنتظره‌ای می‌شکند. برای مثال، اگر یک API دیگر یک استثناء خاص را پرتاب نکند، کدی که به گرفتن آن استثناء متکی است، نادرست رفتار خواهد کرد.

نتیجه‌گیری
توسعه مبتنی بر استثناء نشان‌دهنده استفاده نادرست از مکانیسم‌های مدیریت استثناء است که منجر به کدی می‌شود که خواندن، نگهداری و اشکال‌زدایی آن دشوارتر است. در حالی که استثناها یک ویژگی قدرتمند زبان‌های برنامه‌نویسی مدرن هستند، باید برای شرایط واقعاً استثنایی رزرو شوند نه اینکه به عنوان یک مکانیسم جریان کنترل روتین مورد استفاده قرار گیرند. با درک مشکلات EDD و اتخاذ الگوهای جایگزین مانند گارد کلازها، الگوی TryPattern یا انواع Result، توسعه‌دهندگان می‌توانند کدی مقاوم‌تر، قابل نگهداری‌تر و کارآمدتر ایجاد کنند. بازسازی کد مبتنی بر استثناء موجود ممکن است نیازمند تلاش باشد، اما مزایای آن از نظر کیفیت کد، عملکرد و قابلیت نگهداری ارزش این تلاش را دارد. به یاد داشته باشید که استثناها ابزارهایی هستند که برای شرایط استثنایی طراحی شده‌اند. هنگامی که به طور مناسب استفاده شوند، به جداسازی منطق مدیریت خطا از جریان اصلی برنامه کمک می‌کنند. با این حال، هنگامی که بیش از حد یا به اشتباه استفاده شوند، می‌توانند به ضدالگوی توسعه مبتنی بر استثناء با تمام مشکلات مرتبط با آن منجر شوند.


مشاهده مطلب اصلی