Понимание практических преимуществ использования принципа единой ответственности

Я пытаюсь понять SRP, но, хотя я понимаю аргументы в пользу того, как его применять, я не вижу в этом преимущества. Рассмотрим этот пример, взятый у Роберта Мартина SRP PDF:

interface IModem
{
    void Dial(string number);
    void Hangup();
    void Send(char c);
    char Recv();
}

Он предлагает разделить это на два интерфейса:

interface IModemConnection
{
    void Dial(string number);
    void Hangup();
}

interface IModemDataExchange
{
    void Send(char c);
    char Recv();
}

Я также читал эту статью, которая делает следующий шаг:

interface IModemConnection : IDisposable
{
    IModemDataExchange Dial(string number);
}

interface IModemDataExchange
{
    void Send(char c);
    char Recv();
}

В этот момент я понимаю, что подразумевается под функциональными (Send / Recv) и нефункциональными (Dial / Hangup) аспектами, но я не вижу преимущества их разделения в этом примере. Учитывая эту базовую реализацию:

class ConcreteModem : IModemConnection
{
    public IModemDataExchange Dial(string number)
    {
        if (connection is successful)
        {
            return new ConcreteModemDataExchange();
        }

        return null;
    }

    public void Dispose()
    {
        // 
    }

    public bool IsConnected { get; private set; }
}

В этот момент позвольте мне еще раз процитировать Роберта Мартина, хотя он говорит о другом примере из этого PDF:

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

Это то, чего я не понимаю. Если бы мне пришлось создать вторую реализацию IModemDataExchange, и я хотел бы использовать ее, мне все равно пришлось бы изменить метод Dial, то есть класс также должен быть перекомпилирован:

public IModemDataExchange Dial(string number)
{
    if (some condition is met)
    {
        return new ConcreteModemDataExchange();
    }
    else if (another condition is met)
    {
        return new AnotherConcreteModemDataExchange();
    }

    return null;
}

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

Ответ 1

Мне модемный пример выше всегда казался примером для принципа сегрегации интерфейса, а не для SRP, но кроме того.

В той части, которую вы вызвали в отношении Rectangle, я думаю, вы просто неправильно интерпретируете ее. Мартин использует Rectangle в качестве примера общей библиотеки. Если для параметра GraphicalApplication требуется новый метод или изменение семантики в классе Rectangle, это влияет на ComputationalGeometryApplication, поскольку они оба "ссылаются" на библиотеку Rectangle. Он говорит, что это нарушает SRP, потому что он отвечает за определение границ рендеринга, а также за алгебраическое понятие. Представьте себе, изменился ли GraphicalApplication с DirectX на OpenGL, где y-координата инвертирована. Вы можете изменить некоторые вещи на Rectangle, чтобы облегчить это, но затем вы потенциально вызываете изменения в ComputationalGeometryApplication.

В своей работе я стараюсь следовать принципам SOLID и TDD, и я обнаружил, что SRP делает простые тесты для простых классов, а также поддерживает классы сосредоточены. Классы, которые следуют за SRP, как правило, очень малы, и это снижает сложность как кода, так и зависимостей. Когда вы занимаетесь занятиями, я стараюсь, чтобы класс "делал одну вещь" или "координировал две (или более) вещи". Это позволяет им сфокусироваться и делает свои причины для изменений только зависимыми от того, что они делают, что для меня является точкой SRP.

Ответ 2

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

Если мне пришлось создать вторую реализацию IModemDataExchange, и я хотел бы использовать ее, мне все равно пришлось бы изменить метод набора

Да, придется, но в этом нет пользы. Одно из преимуществ заключается в том, что, когда у вас есть какие-либо изменения для интерфейса IModemDataExchange, вам нужно только изменить конкретные реализации интерфейса, а не ConcreteModem, что облегчит обслуживание подписчиков Dial. Другое преимущество заключается в том, что теперь, даже если вам нужно написать дополнительную реализацию IModemDataExchange, тогда изменения, которые потребуются в классе ConcreteModem, минимизированы, нет прямой связи. Отделяя обязанности, вы минимизируете побочные эффекты модификаций.

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

Ответ 3

Вам не нужно менять ConcreteModem, если вы используете абстрактный factory. Или если вы параметризуете общий Modem<TModemDataExchange> (или общий метод Dial<TModemDataExchange>()) конкретным типом, который должен быть создан при успешном завершении.

Идея заключается в том, что реализация IModemConnection не зависит от какой-либо информации о реализации IModeDataExchange, кроме ее имени.

Двигаясь вперед, я бы рассмотрел следующий подход:

interface IModemConnection : IDisposable
{
    void Dial(string number);
}

interface IModemDataExchange
{
    void Send(char c);
    char Recv();
}

class ConcreteModemDataExchange : IModemDataExchange
{
    ConcreteModemDataExchange(IModemDataExchange);
}

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

Как сторона node, я бы рекомендовал исключить исключение в Dial при ошибке.

Ответ 4

Я не знаю много о том, как работают модемы, поэтому я немного попытался придумать осмысленный пример. Однако учтите следующее:

Отделив логику набора номера, теперь, если некоторые другие части программы нужно только набрать, мы можем передать только IModemConnection. Это может быть полезно даже в самом модемом, используя инъекцию зависимостей:

public class Modem : IModemConnection, IModemDataExchange
{
    public IModemConnection Dialer {get; private set;}

    public Modem(IModemConnection Dialer)
    {
        this.Dialer=Dialer;
    }

    public void Dial(string number)
    {
        Dialer.Dial(number);
    }

    public void Hangup()
    {
        Dialer.Hangup();
    } 

    // .... implement IModemDataExchange
}

Теперь у вас может быть:

public class DigitalDialer : IModemConnection
{
    public void Dial(string number)
    {
        Console.WriteLine("beep beep");
    }
    public void Hangup()
    {
        //hangup
    }
}

и

public class AnalogDialer : IModemConnection
{
    public void Dial(string number)
    {
        Console.WriteLine("do you even remember these?");
    }
    public void Hangup()
    {
        //hangup
    }
}

теперь, если вы хотите изменить некоторые аспекты работы вашего модема (способ, которым он набирает номер в этом случае), ваши изменения будут локализованы в классе Dialer, у которого есть отдельная ответственность (набор номера).