次の方法で共有


Базовый класс в середине иерархии

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

Вот, кажущаяся безумной, но совершенно реальная ситуация, о которой мне недавно сообщил один пользователь. Есть три сборки: Alpha.DLL, Bravo.DLL и Charlie.DLL. Код этих сборок следующий:

public class Alpha // In Alpha.DLL
{
  public virtual void M()
  {
    Console.WriteLine("Alpha");
  }
}

public class Bravo: Alpha // In Bravo.DLL
{
}

public class Charlie : Bravo // In Charlie.DLL
{
  public override void M()
  {
    Console.WriteLine("Charlie");
    base.M();
  }
}

Все совершенно логично. Вы вызываете метод M экземпляра класса Charie, при выполнении кода вы получите “Charlie / Alpha”.

Теперь поставщик, который предоставляет Bravo.DLL выпускает новую версию своего кода:

public class Bravo: Alpha
{
  public override void M()
  {
    Console.WriteLine("Bravo");
    base.M();
  }
}

Вопрос следующий: что произойдет при вызове Charlie . Mбез перекомпиляции Charlie . DLL , но при загрузке новой версии Bravo . DLL ?

Пользователь весьма удивится, увидев, что вывод будет все тем же: “Charlie / Alpha”, а не “Charlie / Bravo / Alpha”.

Это совершенно новое проявление «проблемы неудачного базового класса»; по крайней мере, для меня.

Пользователь: что здесь происходит?

Когда компилятор генерирует код для вызова метода базового класса, он просматривает все метаданные и видит, что ближайшим корректным базовым методом является метод Alpha.Foo. Так что компилятор генерирует код, который говорит следующее: “выполни невиртуальный вызов метода Alpha.Foo”. Этот код «выжжен» в Charlie.DLL и будет иметь одно и то же поведение независимо от того, что находится в Bravo.DLL. В любом случае будет вызван Alpha.Foo.

Пользователь: Вы знаете, если вы сгенерируете код, который говорит: «выполни невиртуальный вызов метода Bravo . Foo ”, то CLRвызовет Alpha . Foo , если не будет реализации метода Bravo . Foo .

Нет, на самом деле, я не знаю об этом. Я немного удивлен, что в результате этого не будет ошибки верификации (verification error), да и ладно. Такое поведение кажется правдоподобным, хотя и несколько рискованным. Быстрый взгляд на документацию семантики инструкции вызова метода показывает, что это предусмотренное поведение, так что оно совершенно корректно.

Пользователь: Так почему же компилятор не генерирует вызов метода Bravo . Foo ? В таком случае мы получим правильную семантику в моем случае!

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

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

Пользователь: что за философское оправдание?

Существует две конкурирующие «философские модели» того, что означает “base.Foo”.

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

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

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

В вашей модели, вызов на самом деле не является виртуальным, поскольку он не основывается на содержимом виртуального слота приемника. Но он и не полностью основывается на информации о типе приемника времени компиляции! Он основывается на комбинации того и другого. По-сути, таким мог бы быть невиртуальный вызов в нереальном мире, в котором компилятор имел бы корректную информацию о том, что же будут представлять из себя типы во время выполнения.

Разработчики, которые придерживаются первой модели (как я, например) будут глубоко удивлены предложенным вами поведением. Если у разработчика есть классы Giraffe (Жираф), Mammal (Млекопитающее) и Animal (Животное), при этом класс Giraffe переопределяет виртуальный метод Animal.Feed, и разработчик говорит base.Feed в методе класса Giraffe, тогда разработчик думает также как и я:

Я хочу, чтобы именно Animal.Feed был вызван в этом месте; если во время выполнения выяснится, что какой-то злой хакер вставил метод Mammal.Feed, о котором я не имел ни малейшего понятия во время компиляции, я все еще буду хотеть, чтобы был вызван метод Animal.Feed. Во время компиляции я хотел вызвать Animal.Feed, я тестировал этот сценарий и я ожидаю увидеть именно этот вызов. Вызов метода базового класса дает мне на 100% безопасное, предсказуемое, не динамическое, тестируемое поведение любого другого невиртуального вызова. Я полагаюсь на эти инварианты, чтобы сохранить данные моего пользователя в безопасности.

По-сути, это позиция следующая: «Я доверяю только тому, что я могу увидеть при написании кода; другой код может не делать того, что я считаю безопасным или корректным».

А вы можете сказать:

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

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

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

Пользователь: Философская причина не убедительна; Для меня вызов метода базового класса означает «вызов ближайшего метода в виртуальной иерархии». А что за практическая проблема?

Осенью 2000-го года, во время разработки компилятора C# 1.0, поведение компилятора было именно таким, как вы ожидаете: мы генерировали вызов метода Bravo.M и позволяли CLR определять во время выполнения будет ли это вызов метода Bravo.M, если такой существует, или Alpha.M, в противном случае. Мой предшественник Питер Халлам (Peter Hallam) потом выяснил следующий момент. Предположим, что новая версия Bravo.DLL, на горячую заменена следующей:

public class Bravo: Alpha
{
  new private void M()
  {
    Console.WriteLine("Bravo");
  }
}

Что будет в таком случае? Класс Bravo добавил закрытый метод, а один из наших принципов проектирования заключается в том, что закрытые методы являются лишь невидимыми деталями реализации; они не оказывают никакого эффекта на окружающий код, который не может их видеть. Если вы сгоряча измените этот код, таким образом, что из класса Charlie будет вызван метод Bravo.M, то это приведет к краху во время выполнения. Вызов базового класса будет определен как вызов закрытого метода извне этого метода, что является некорректным. Соответствие невиртуальных вызовов осуществляется по сигнатуре, а не по виртуальному слоту.

Архитекторы CLR и C# рассматривали множество возможных решений этой проблемы, включая добавление новой инструкции, которая бы находила соответствие по слоту, изменение семантики инструкции вызова метода, изменение значения “private”, реализацию искажения имен (name mangling) компилятором и т.п. Они пришли к решению, что все вышеописанное было безумно опасным, принимая во внимание позднюю стадию цикла выпуска, невысокую вероятность такого сценария и тот факт, что это позволит осуществлять сценарии, которые прямо противоречат здравому смыслу; если вы измените базовый класс, вам следует перекомпилировать производные классы. Мы не хотим помогать вам совершать опасные и ошибочные поступки.

Так что они отложили эту проблему. Компилятор C# 1.0 делает это так, как вам хочется и генерирует код, который иногда будет падать во время выполнения, если вы добавите новый закрытый метод: в первоначально скомпилированной сборке в классе Charlie будет вызван метод Bravo.M, даже если такого метода не существует. Если позднее окажется, что этот метод не доступен, тогда выполнение рухнет. Если вы перекомпилируете Charlie . DLL , тогда компилятор увидит промежуточный закрытый метод, который приведет к краху времени выполнения и сгенерирует вызов Alpha . M .

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

Так продолжалась до 2003-го года, когда команда языка C# снова подняла этот вопрос с командой CLR, чтобы подумать о добавлении новой инструкции, как например, инструкции “basecall”, которая бы указывала на конкретный виртуальный слот, а не находила бы это соответствие по сигнатуре, так как сейчас это делает инструкция невиртуального вызова. После длительных обсуждений, опять пришли к выводу, что этот непонятный и опасный сценарий не стоит того, чтобы вносить чрезвычайно дорогие и потенциально разрушительные изменения в CLR .

Обеспокоенные поведением, которое может приводить к поломкам и плохой производительности, в 2003-м году команда проектировщиков языка C# решила придерживаться текущего подхода, напрямую привязываясь к слоту, известному во время компиляции. Вся команда согласилась с тем, что желательное поведение сводилось к динамической привязке к ближайшему базовому классу (точка зрения, с которой я не согласен, но которую я понимаю). Но учитывая затраты на безопасную реализацию, и тот факт, что горячая замена нового кода в середине иерархии наследования – это не совсем то поведение, которое следует поддерживать, лучше заставить пользователя перекомпилировать свой (что вы должны делать все время), вместо того, чтобы рухнуть во время выполнения и погибнуть в муках.

Пользователь: Ух ты. Так это поведение никогда не изменится, так что ли?

Ух ты, и правда. Я сегодня узнал невероятно много. Это один из дней, когда мне пришлось сидеть и читать все пятьсот страниц заметок по дизайну языков C# 1.0 и 2.0.

Я бы не рассчитывал, что это поведение когда-либо изменится. Если вы изменяете базовый класс, перекомпилируйте и производные. Это безопасный способ. Не полагайтесь на то, что инфраструктура времени выполнения исправит все за вас, когда вы замените класс в середине иерархии наследования.

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

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

(2) если имело место критическое изменение, перекомпилируйте, протестируйте заново, и заново разверните производный тип. И

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

Я согласен с тем, что весьма неудачно, что добавление нового переопределения может в некоторых редких случаях приводить к аварии. Я надеюсь, вы согласитесь, что также весьма неудачно, что добавление нового закрытого метода в некоторых редких случаях приводит к краху во время выполнения в C# 1.0. Какое из этих зол меньшее, конечно, вопрос спорный; мы обсуждали это с 2000 до 2003-го года и я не думаю, что это разумно или продуктивно критиковать задним числом результаты тех споров.

Простой бесспорный факт заключается в том, что проблема «неудачного базового класса», является неотъемлемой проблемой при объектно-ориентированном программировании. Мы приложили максимум усилий для проектирования языка, в котором вероятность возникновения проблемы «неудачного базового класса» была бы минимальной. Команда, работающая над BCL, прилагает все усилия для гарантии того, что обновления после установки пакетов исправлений вносили настолько мало изменений, ломающих код, насколько это возможно, удовлетворяя при этом другим целям, таким, как устранение существующих проблем. Но всех наших усилий недостаточно, поскольку в мире существует множество базовых классов, которые не содержатся в BCL.

Если вас часто одолевает проблема «неудачных базовых классов», тогда, возможно, объектно-ориентированное программирование не лучшее решение для вашей предметной области; существуют другие подходы к повторному использованию кода, которые оказываются вполне применимыми и которые не страдают от проблемы «неудачного базового класса».

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