Почему я не могу получить доступ к защищенному члену из производного класса? Часть 6

Читатель Джес Макгрю (Jesse McGrew) задал отличный дополнительный вопрос к моему сообщению 2005 года о том, почему вы не можете получить доступ к защищенному члену из производного класса. (Вам, видимо, стоит прочитать еще раз эти те сообщения, чтобы легче понять смысл этого).

Я хочу прояснить вопросы терминологии. Предположим, вы вызываете foo.Bar() внутри класса C. Значение foo является «приёмником» (receiver) вызова метода. Тип foo времени компиляции – это «тип приёмника времени компиляции». «Тип приёмника времени выполнения» потенциально может быть следующим наследником. «Типом места вызова» (call site type) является класс С.

Правила языка C# таковы, что если Bar является защищенным методом, тогда тип приёмника времени компиляции должен совпадать с типом места вызова или с типом производного класса. Но тип приёмника не может быть базовым типом для типа места вызова, потому что тип приёмника времени выполнения может оказаться «братом» (дядей, тетей … давайте не будет плодить родственником и назовем их просто братьями) по отношению к типу места вызова, а не только находится в отношении «предок/потомок».

Джес правильно заметил, что мой исходный ответ на вопрос не совсем полон. Остались без ответа два вопроса:

1) Что плохого в разрешении вызова защищенного метода «приёмника» чей тип времени выполнения является «братом»?

2) Ради убедительности предположим, что существует разумный ответ на первый вопрос, но почему тогда этот же аргумент не может быть применен к вызову защищенного метода «приёмника» чей статический тип совпадает с типом места вызова? Тип приёмника времени выполнения все еще может быть более производным.

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

// Good.dll:
    public abstract class BankAccount
    {
      abstract protected void DoTransfer(
        BankAccount destinationAccount, 
        User authorizedUser,
        decimal amount);
    }
    public abstract class SecureBankAccount : BankAccount
    {
      protected readonly int accountNumber;
      public SecureBankAccount(int accountNumber)
      {
        this.accountNumber = accountNumber;
      }
      public void Transfer(
        BankAccount destinationAccount, 
        User authorizedUser,
        decimal amount)
      {
        if (!Authorized(user, accountNumber)) throw something;
        this.DoTransfer(destinationAccount, user, amount);
      }
    }
    public sealed class SwissBankAccount : SecureBankAccount
    {
      public SwissBankAccount(int accountNumber) : base(accountNumber) {}
      override protected void DoTransfer(
        BankAccount destinationAccount, 
        User authorizedUser,
        decimal amount)
      {
        // Код перевода денег со счета Швейцарского банка.
// Этот код может предполагать, что пользователь authorizedUser прошел авторизацию.
// Это гарантируется тем, что класс SwissBankAccount является закрытым (sealed)
// и все вызовы должны проходить через открытую версию функции Transfer
// базового класса SecureBankAccount.
}
}
// Evil.exe:
class HostileBankAccount : BankAccount
{
override protected void Transfer(
BankAccount destinationAccount,
User authorizedUser,
decimal amount) { }
public static void Main()
{
User drEvil = new User("Dr. Evil");
BankAccount yours = new SwissBankAccount(1234567);
BankAccount mine = new SwissBankAccount(66666666);
yours.DoTransfer(mine, drEvil, 1000000.00m); // ошибка компиляции
// У вас нет прав на доступ к защищенным членам класса
// SwissBankAccount просто потому, что вы сейчас находитесь в классе производном
// от BankAccount.
}
}

Попытка Доктора Зло украсть ОДИН… МИЛЛИОН ДОЛЛАРОВ… с вашего счета в Швейцарском банке была предотвращена компилятором C#.

Конечно, это глупый пример, и код с полным доверием (fully-trusted code) может делать все что угодно с вашими типами: он может запустить отладчик и изменить уже выполняемый код. Полное доверие означает полное доверие. Повторюсь, не проектируйте реальные системы безопасности таким способом!

Идея в том, что предотвращенная здесь «атака» была направлена на попытку обойти инварианты установленные классом SecureBankAcount для прямого доступа к коду класса SwissBankAccount .

Второй вопрос такой: «А разве SecureBankAccount не имеет таких же ограничений?» В моем примере SecureBankAccount вызывает “this.DoTransfer(destinationAccount, user, amount);”

Очевидно, что “this” относится к типу SecureBankAccount или к производному типу. Это может быть любой производный класс, включая новый класс SwissBankAccount. Может ли здесь возникнуть та же самая проблема? Может ли SecureBankAccount обойти инварианты SwissBankAccount ?

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

Кроме того, базовый класс пишется до написания классов-потомков. Базовый класс не будет изменяться под вас, и вы, вероятно, рассчитываете, что его автор не будет вероломно ломать ваш код своими новыми версиями. (Конечно, изменения базового класса могут приводить к проблемам; это еще один вариант проблемы «хрупких базовых классов»)

Разница между этими двумя случаями в том, что при наследовании от базового класса вам нужно понимать и одобрять поведение одного выбранного вами класса. Это контроллируемый объем работ. Авторам SwissBankAccount нужно четко понимать, что для класса SecureBankAccount точно является инвариантом перед вызовом защищенного метода. Но им не нужно понимать и доверять каждому методу каждого родственного класса, наследующего от общего базового класса. Эти классы могут быть реализованы кем угодно и делать все что угодно. У вас не будет возможности хоть как-то понять инвариант каждого метода, поэтому не будет и возможности написать работающий защищенный метод. Поэтому мы оберегаем вас от этих хлопот и запрещаем такой сценарий.

Кроме того, мы разрешаем вам вызывать защищенные методы в приёмниках потенциальных производных классов. Предположим мы не позволяем вам этого вследствие каких-то глупых выводов. Каким вообще образом может быть вызван защищенный метод, если мы запретим вызов защищенного метода приёмников потенциального производного класса? Тогда единственной возможностью вызова защищенного метода будет вызов собственного защищенного метода внутри закрытого (sealed) класса! Фактически, вызов защищенных методов будет практически невозможен, а в случае вызова будет вызван метод самого производного класса. Какой смысл «защищенности» в этом случае? Тогда «защищенный» будет означать то же самое, что и «закрытый метод, который может быть вызван только в закрытом (sealed) классе». Это сделает их практически бесполезными.

Итак, короткий ответ на оба вопроса следующий: «потому что, если мы не сделаем этого, защищенные методы будет невозможно использовать вовсе». Мы ограничиваем вызовы защищенных методов предшественников приёмниками типов времени компиляции, потому что в противном случае будет невозможна безопасная реализация защищенных методов, зависящих от инвариантов. Мы разрешаем вызовы потенциальных наследников, поскольку в противном случае будут запрещены вообще практически любые вызовы.

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

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

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

Оригинал статьи