Pull to refresh

Как правильно использовать исключения

Reading time6 min
Views47K
Original author: Vladimir Khorikov
Использование исключений для контроля хода выполнения программы (flow control) — давняя тема. Я хотел бы суммировать этот топик и привести примеры правильного и неправильного использования исключений.

Исключения вместо if-ов: почему нет?


В большинстве случаев, мы читаем код чаще, чем пишем. Большинство практик программирования нацелены на упрощение понимания кода: чем проще код, тем меньше багов он содержит и тем проще его поддержка.

Использование исключений для контроля хода выполнения программы маскирует намерения программиста, поэтому это считается плохой практикой.

public void ProcessItem(Item item)
{
    if (_knownItems.Contains(item))
    {
        // Do something
        throw new SuccessException();
    }
    else
    {
        throw new FailureException();
    }
}

Метод ProcessItem усложняет понимание кода, т.к. невозможно сказать какие возможны результаты его выполнения просто глядя на его сигнатуру. Подобный код нарушает принцип наимешего удивления, т.к. бросает исключение даже в случае положительного исхода.

В данном конкретном случае решение очевидно — нужно заменить выбрасывание исключения на возврат булевского значения. Давайте рассмотрим более сложные примеры.

Исключения для валидации входящих данных


Возможно, наиболее часто встречающаяся практика в контексте исключений — это использование их в случае получения неверных входящих данных.

public class EmployeeController : Controller
{
    [HttpPost]
    public ActionResult CreateEmployee(string name, int departmentId)
    {
        try
        {
            ValidateName(name);
            Department department = GetDepartment(departmentId);
 
            // Rest of the method
        }
        catch (ValidationException ex)
        {
            // Return view with error
        }
    }
 
    private void ValidateName(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ValidationException(“Name cannot be empty”);
 
        if (name.Length > 100)
            throw new ValidationException(“Name length cannot exceed 100 characters”);
    }
 
    private Department GetDepartment(int departmentId)
    {
        using (EmployeeContext context = new EmployeeContext())
        {
            Department department = context.Departments
                .SingleOrDefault(x => x.Id == departmentId);
 
            if (department == null)
                throw new ValidationException(“Department with such Id does not exist”);
 
            return department;
        }
    }
}

Очевидно, подобный подход имеет некоторые плюсы: он позволяет нам быстро «вернуться» из любого метода прямо в catch блок метода CreateEmployee.

Теперь давайте посмотрим на следующий пример:

public static Employee FindAndProcessEmployee(IList<Employee> employees, string taskName)
{
    Employee found = null;
 
    foreach (Employee employee in employees)
    {
        foreach (Task task in employee.Tasks)
        {
            if (task.Name == taskName)
            {
                found = employee;
                goto M1;
            }
        }
    }
 
    // Some code
 
    M1:
    found.IsProcessed = true;
 
    return found;
}

Что общего имеют эти два сэмпла? Оба они позволяют прервать текущий поток выполнения и быстро перейти к определенной точке в коде. Единственная проблема в таком коде — он существенно ухудшает читаемость. Оба подхода затрудняют понимание кода, именно поэтому использование исключений для контроля потока выполнения программы часто уравнивают с использованием goto.

При использовании исключений сложно понять где именно они ловятся. Вы можете обернуть код, выбрасывающий исключение, в try/catch блок в том же методе, а можете поместить try/catch блок на несколько уровней по стеку выше. Вы никогда не можете знать наверняка сделано ли это намерено или нет:

public Employee CreateEmployee(string name, int departmentId)
{
    // Это баг или метод специально был помещен сюда без try/catch блока?
    ValidateName(name);
           
    // Rest of the method
}

Единственный способ узнать это — проанализировать весь стек. Исключения, использующиеся для валидации, делают код намного менее читаемым, т.к. они недостаточно ясно показывают намерения разработчика. На такой код невозможно просто посмотреть и сказать что может пойти не так и как необходимо отреагировать на это.

Есть ли способ лучше? Конечно:

[HttpPost]
public ActionResult CreateEmployee(string name, int departmentId)
{
    if (!IsNameValid(name))
    {
        // Return view with error
    }
 
    if (!IsDepartmentValid(departmentId))
    {
        // Return view with another error
    }
 
    Employee employee = new Employee(name, departmentId);
    // Rest of the method
}

Указание всех проверок явным образом делает ваши намерения намного более понятными. Эта версия метода проста и очевидна.

Исключения для исключительных ситуаций


Так когда же использовать исключения? Главная цель исключений — сюрприз! — обозначить исключительную ситуацию в приложении. Исключительная ситуация — это ситуация, в которой вы не знаете что делать и наилучшим выходом для вас является прекращение выполнения текущией операции (возможно, с предварительным логированием деталей ошибки).

Примерами исключительных ситуаций могут являться проблемы с подключением к БД, отсутствие необходимых конфигурационных файлов и т.п. Ошибки валидации не являются исключительной ситуацией, т.к. метод, проверяющий входящие данные, по определению ожидает что они могут быть неверными.

Другим примером корректного использования исключений является валидация контрактов (code contract). Вы, как автор класса, ожидаете, что клиенты этого класса будут соблюдать его контракты. Ситуация, при которой контракт метода не соблюден, является исключительной и заслуживает выбрасывания исключения.

Как работать с исключениями, брошенными другими библиотеками?


Является ли ситуация исключительной, зависит от контекста. Разработчик сторонней библиотеки может не знать как иметь дело с проблемами подключения к БД, т.к. он не знает в каком контексте будет использоваться его библиотека.

В случае подобной проблемы, разработчик библиотеки не имеет возможности что-либо с ней сделать, поэтому бросание исключения будет подходящим решением. Вы можете взять Entity Framework или NHibernate в качестве примера: они ожидают, что БД всегда доступна и если это не так, бросают исключение.

С другой стороны, разработчик, использующий библиотеку, может ожидать, что база данных уходит в офлайн время от времени и разрабатывать свое приложение с учетом этого факта. В случае отказа БД, клиентское приложение может попытаться повторить ту же операцию или отобразить сообщение пользователю с предложением повторить операцию позже.

Таким образом, ситуация может быть исключительной с точки зрения нижележащего кода и ожидаемой с точки зрения клиентского кода. Как в этом случае работать с исключениями, брошенными такой библиотекой?

Подобные исключения должны ловиться как можно ближе к коду, их выбрасыющему. Если это не так, ваш код будет иметь те же недостатки, что и пример кода с goto: невозможно будет понять где это исключение обрабатывается без анализа всего стека вызова.

public void CreateCustomer(string name)
{
    Customer customer = new Customer(name);
    bool result = SaveCustomer(customer);
 
    if (!result)
    {
        MessageBox.Show(“Error connecting to the database. Please try again later.”);
    }
}
 
private bool SaveCustomer(Customer customer)
{
    try
    {
        using (MyContext context = new MyContext())
        {
            context.Customers.Add(customer);
            context.SaveChanges();
        }
        return true;
    }
    catch (DbUpdateException ex)
    {
        return false;
    }
}

Как можно видеть на примере выше, метод SaveCustomer ожидает проблемы с базой данных и намеренно отлавливает все ошибки, связанные с этим. Он возвращает булевский флаг, который затем обрабатывается кодом выше по стеку.

Метод SaveCustomer имеет понятную сигнатуру, говорящую нам, что в процессе сохранения клиента могут быть проблемы, что эти проблемы ожидаемы и что вы должны проверить возвращаемое значение чтобы убедиться, что все в порядке.

Стоит отметить широко известную практику, применимую в данном случае: не нужно оборачивать подобный код в generic обработчик. Generic обработчик утверждает, что любые исключения являются ожидаемыми, что по сути неправда.

Если вы действительно ожидаете какие-либо исключения, вы делаете это для очень ограниченного их числа, для которого вы точно знаете, что вы можете их обработать. Помещение generic обработчика приводит к тому, что вы проглатываете не ожидаемые вами исключения, приводя приложение в неконсистентное состояние.

Единственная ситуация, в которой generic обработчики применимы — это размещение их на самом высоком уровне по стеку приложения для отлова всех исключений, не пойманных кодом ниже, для того, чтобы залогировать их. Подобные исключения не следует пытаться обработать, все что можно сделать — это закрыть приложение (в случае со stateful приложением) или прекратить текущую операцию (в случае со stateless приложением).

Исключения и fail-fast принцип



Как часто вы встречаете код подобный этому?

public bool CreateCustomer(int managerId, string addressString, string departmentName)
{
    try
    {
        Manager manager = GetManager(managerId);
        Address address = CreateAddress(addressString);
        Department department = GetDepartment(departmentName);
 
        CreateCustomerCore(manager, address, department);
        return true;
    }
    catch (Exception ex)
    {
        _logger.Log(ex);
        return false;
    }
}

Это пример некорректного использования generic обработчика исключений. Код выше подразумевает, что все исключения, приходящие из тела метода, являются признаком ошибки в процессе создания кастомера. В чем проблема подобного кода?

Помимо схожести с «goto» семантикой, обсуждаемой выше, пробема состоит в том, что исключение, приходящее в catch блок может не быть известным нам исключением. Исключение может быть как ArgumentException, которое мы ожидаем, так и ContractViolationException. В последнем случае, мы прячем баг, притворяясь, что нам известно как обрабатывать подобное исключение.

Итогда подобный подход применяется разработчиками, которые хотят защитить свое приложение от неожиданных падений. На самом же деле подобный подход только маскирует проблемы, делая более сложным процесс их вылавливания.

Наилучший способ работы с неожидаемыми исключениями — прекратить текущую операцию полностью и предотвратить распространение неконсистентного состояния по приложению.

Заключение


  • Бросайте исключение тогда и только тогда, когда надо задекларировать исключительную ситуацию в коде.
  • Используйте возвращаемые значения при валидации входящих данных.
  • Если вы знаете, как обрабатывать исключения, бросаемые библиотекой, делайте это как можно ближе к коду, их бросающему.
  • Если вы имеете дело с неожидаемым исключением, прекращайте текущую операцию полностью. Не притворяйтесь, что знаете как иметь дело с такими исключениями.

Ссылка на оригинал статьи: Exceptions for flow control in C#
Tags:
Hubs:
+18
Comments36

Articles