C#: обратная совместимость и перегрузки +19


Здравствуйте, коллеги!

Напоминаем всем, что у нас вышла отличная книга Марка Прайса "C# 7 и .NET Core. Кросс-платформенная разработка для профессионалов". Обратите внимание: перед вами уже третье издание, первое издание было написано по версии 6.0 и на русском языке не выходило, а 3-е издание вышло в оригинале в ноябре 2017 года и охватывает версию 7.1.


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

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

Так, в экосистеме .NET обычно приветствуется семантическое версионирование – звучит отлично, но требует, чтобы все одинаково понимали, что считается «принципиальным изменением». Именно об этом я долго размышлял. Один из аспектов, наиболее поразивших меня недавно – насколько сложно избежать принципиальных изменений при перегрузке методов. Именно об этом (в основном) пойдет речь в посте, который вы читаете; в конце концов, эта тема очень интересна.
Для начала – краткое определение…

Исходники и двоичная совместимость

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

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

Итак, о чем же мы говорим?

Допустим, у нас есть общедоступная библиотека версии 1.0, и мы хотим добавить в нее несколько перегрузок, чтобы доработать до версии 1.1. Мы придерживаемся семантического версионирования, поэтому нам требуется обратная совместимость. Что это значит, что мы можем и чего не можем делать, и на все ли вопросы здесь можно ответить «да» или «нет»?

В разных примерах я буду показывать код в версии 1.0 и 1.1, а затем «клиентский» код (т.е., код, использующий библиотеку), который может сломаться в результате изменений. Здесь не будет ни тел методов, ни объявлений классов, поскольку они, в сущности, не важны – основное внимание уделяем сигнатурам. Однако, если вам интересно, то все эти классы и методы можно легко воспроизвести. Предположим, что все методы, о которых здесь рассказывается, находятся в классе Library.

Простейшее мыслимое изменение, украшенное преобразованием группы методов к делегату
Простейший пример, который приходит мне на ум – добавление параметризованного метода там, где уже есть непараметризованный:

	// Библиотечная версия 1.0
public void Foo()
 
// Библиотечная версия 1.1
public void Foo()
public void Foo(int x)


Даже здесь совместимость неполная. Рассмотрим следующий клиентский код:

	// Клиент
static void Method()
{
    var library = new Library();
    HandleAction(library.Foo);
}
 
static void HandleAction(Action action) {}
static void HandleAction(Action<int> action) {}

В первой версии библиотеки все нормально. Вызов метода HandleAction дает преобразование группы методов к делегату library.Foo, и в результате создается Action. В версии 1.1 ситуация становится неоднозначной: группа методов может быть преобразована к Action или Action. То есть, строго говоря, такое изменение несовместимо с исходным кодом.

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

Несвязанные ссылочные типы

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

// Версия 1.0
public void Foo(string x)
 
// Версия 1.1
public void Foo(string x)
public void Foo(FileStream x)

На первый взгляд все логично. Исходный метод у нас сохраняется, поэтому двоичной совместимости мы не нарушим. Простейший способ ее нарушить – написать вызов, который работает в v1.0, но не работает в v1.1, либо работает в обеих версиях, но по-разному.
Какую несовместимость между v1.0 и v1.1 может дать такой вызов? У нас должен быть аргумент, совместимый как со string, так и с FileStream. Но это – не связанные друг с другом ссылочные типы…

Первый отказ возможен, если мы сделаем определяемое пользователем неявное преобразование как к string, так и к FileStream:

// Клиент
class OddlyConvertible
{
    public static implicit operator string(OddlyConvertible c) => null;
    public static implicit operator FileStream(OddlyConvertible c) => null;
}
 
static void Method()
{
    var library = new Library();
    var convertible = new OddlyConvertible();
    library.Foo(convertible);
}

Надеюсь, проблема очевидна: код, который ранее был однозначен и работал со string, теперь неоднозначен, поскольку тип OddlyConvertible может неявно преобразовываться и к string, и к FileStream (обе перегрузки применимы, ни одна из них не лучше другой).

Может быть, в данном случае разумно запретить преобразования, определяемые пользователем… но этот код можно обрушить и гораздо проще:

// Клиент
static void Method()
{
    var library = new Library();
    library.Foo(null);
}

Нулевой литерал неявно преобразуем к любому ссылочному типу или к любому обнуляемому значимому типу… поэтому, опять же, ситуация в версии 1.1 получается неоднозначной. Попробуем снова…

Параметры ссылочных типов и необнуляемых значимых типов

Допустим, нас не волнуют преобразования, определяемые пользователем, зато нам не нравятся проблемные нулевые литералы. Как в данном случае добавить перегрузку с необнуляемым значимым типом?

	// Версия 1.0
public void Foo(string x)
 
// Версия 1.1
public void Foo(string x)
public void Foo(int x)

На первый взгляд хорошо – library.Foo(null) будет нормально работать в v1.1. Так безопасен ли он? Нет, только не в C# 7.1…

	// Клиент
static void Method()
{
    var library = new Library();
    library.Foo(default);
}

Литерал, задаваемый по умолчанию – точно как нулевой, но применим к любому типу. Это очень удобно – и сплошная головная боль, когда речь заходит о перегрузках и совместимости :(

Опциональные параметры

Опциональные параметры – та еще проблема. Допустим, у нас есть один опциональный параметр, а мы хотим добавить второй. У нас три варианта, обозначенных ниже как we 1.1a, 1.1b и 1.1c.

	// Версия 1.0
public void Foo(string x = "")
 
// Версия 1.1a
// Оставляем имеющийся метод, но добавляем еще один с двумя опциональными
 параметрами
public void Foo(string x = "")
public void Foo(string x = "", string y = "")
 
// Версия 1.1b
// Просто добавляем еще один параметр к уже имеющемуся методу
public void Foo(string x = "", string y = "")
 
// Версия 1.1c
// Оставляем старый метод, но делаем параметр обязательным, и добавляем 
// новый метод, у которого оба параметра опциональны.
public void Foo(string x)
public void Foo(string x = "", string y = "")


А что, если клиент будет делать два вызова:

// Клиент
static void Method()
{
    var library = new Library();
    library.Foo();
    library.Foo("xyz");
}

Библиотека 1.1a сохраняет совместимость на уровне двоичного кода, но нарушает на уровне исходного кода: теперь library.Foo() неоднозначна. По правилам перегрузки в C# предпочитаются методы, не требующие от компилятора «заполнять» все имеющиеся опциональные параметры, однако, никак не регламентирует, сколько опциональных параметров может заполняться.

Библиотека 1.1b сохраняет совместимость на уровне исходников, но нарушает двоичную совместимость. Имеющийся скомпилированный код рассчитан на вызов метода с единственным параметром – а такого метода больше не существует.

Библиотека 1.1c сохраняет двоичную совместимость, но чревата возможными сюрпризами на уровне исходного кода. Теперь вызов library.Foo() разрешается в метод с двумя параметрами, тогда как library.Foo("xyz") разрешается в метод с одним параметром (с точки зрения компилятора он предпочтительнее метода с двумя параметрами, в основном потому, что никаких опциональных параметров заполнять не требуется). Это может быть вполне приемлемо, если версия с одним параметром просто делегирует версии с двумя параметрами, и в обоих случаях используется одно и то же значение по умолчанию. Однако, кажется странным, что значение первого вызова будет меняться, если метод, в который он ранее разрешался, по-прежнему существует.

Ситуация с опциональными параметрами становится еще более запутанной, если вы хотите добавлять новый параметр не в конце, а в середине – например, стараетесь придерживаться соглашения и держать опциональный параметр CancellationToken в самом конце. Не стану в это углубляться…

Обобщенные методы

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

Допустим, у нас в v1.0 всего один необобщенный метод, а в v1.1 мы добавляем еще один обобщенный.

// Версия 1.0
public void Foo(object x)
 
// Версия 1.1
public void Foo(object x)
public void Foo<T>(T x)

На первый взгляд не так страшно… но давайте посмотрим, что происходит в клиентском коде:

// Клиент
static void Method()
{
    var library = new Library();
    library.Foo(new object());
    library.Foo("xyz");
}

В библиотеке v1.0 оба вызова разрешаются в Foo(object) – единственный имеющийся метод.

Библиотека v1.1 обратно совместима: если взять исполняемый клиентский файл, скомпилированный для v1.1, то оба вызова все равно будут использовать Foo(object). Но, в случае перекомпиляции второй вызов (и только второй) переключится на работу с обобщенным методом. Оба метода применимы при обоих вызовах.

При первом вызове вывод типов покажет, что T – это object, поэтому преобразование аргумента в тип параметра в обоих случаях сведется к object в object. Отлично. Компилятор применит правило, согласно которому необобщенные методы всегда предпочтительнее обобщенных.

При втором вызове вывод типов покажет, что T всегда будет string, поэтому при преобразовании аргумента в параметр типа получаем string в object для исходного метода или string в string для обобщенного метода. Второе преобразование «лучше», поэтому выбирается второй метод.

Если два метода работают одинаково – отлично. Если нет, то вы нарушите совместимость очень неочевидным образом.

Наследование и динамическая типизация

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

Аналогично выходит и с динамической типизацией (в клиентском коде); до некоторой степени ситуация становится непредсказуемой. Вы уже серьезно пожертвовали безопасностью во время компиляции… поэтому не удивляйтесь, если что-то сломается.

Итог

Я старался, чтобы примеры в этой статье были достаточно просты. Все сильно усложняется, причем очень быстро, когда у вас появляется множество опциональных параметров. Версионирование – сложное дело, у меня от него голова пухнет.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (4):

  1. vshmidt
    /#18786835 / +1

    Кто-нибудь получал похожие примеры в реальной жизни? Насколько фа платные были последствия? По мне так все примеры надуманы

  2. PetSerVas
    /#18786845

    Будет ли электронный вариант?

  3. cheksuhin
    /#18787435

    Добрый день. А будет ли скидка на книгу?