Обратная совместимость интерфейсов DLL и COM — таблица: какие изменения ломают вызывающий код

· Обновлено: · · COM, DLL, .NET, C#, C++, Обратная совместимость, Управление версиями, Легаси, Повторное использование существующего кода, Таблица решений

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21620063)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Обратная совместимость интерфейсов DLL и COM — таблица: какие изменения ломают вызывающий код. KomuraSoft LLC. https://comcomponent.com/ru/blog/dll-com-interface-backward-compatibility/

DOI (зарегистрированный архив)
10.5281/zenodo.21620063
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21620064

«Для этого исправления достаточно подменить DLL или нужно ещё пересобрать вызывающий код?» Если вы сопровождаете общую DLL или COM-компонент, на который ссылаются несколько приложений, этот вопрос возникает на каждом релизе. Ошибиться в ответе значит: старый EXE у заказчика перестанет запускаться — или, хуже, запустится как ни в чём не бывало, а результаты расчётов тихо изменятся.

Неприятность в том, что решение часто принимают по ощущению «вроде рискованно». На самом деле какие изменения ломают совместимость, можно определить почти механически. У нативных DLL есть правила экспорта и соглашений о вызовах; у COM — явно записанное правило «интерфейс неизменен»1; у .NET — перечень правил изменений для совместимости, которым сама Microsoft пользуется при разработке библиотек .NET2.

Основы COM мы разбирали в статье «Что такое COM / ActiveX / OCX», а замысел этой модели — в «Что такое COM — почему дизайн Windows COM до сих пор красив». Здесь для DLL, COM и сборок .NET сводим в таблицу, какие изменения ломают вызывающий код, и описываем, как действовать, когда совместимость всё же приходится нарушить.

Термины, которые используются в этой статье

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

Термин Кратко
ABI (Application Binary Interface) Договорённость между уже скомпилированными двоичными файлами: как передавать аргументы, как лежат структуры в памяти, как именуются символы — контракт на уровне машинного кода, а не исходников
Соглашение о вызовах (calling convention) Часть ABI: аргументы идут через регистры или стек и в каком порядке; кто снимает аргументы со стека — вызывающий или вызываемый (__cdecl / __stdcall и т.д.)
vtable (таблица виртуальных функций) Таблица указателей на функции в фиксированном порядке. Это и есть интерфейс COM: вызывающий код обращается к методу по позиции слота
Порядковый номер экспорта (ordinal) Номер функции в таблице экспорта DLL. Импортировать можно не по имени, а по этому номеру
IID / CLSID / ProgID По порядку: идентификатор интерфейса COM (контракта), идентификатор класса-реализации и человекочитаемый псевдоним CLSID (раздел 4.1)
Строгое имя (strong name) Способ однозначно идентифицировать сборку .NET: имя + версия + культура + токен открытого ключа и подпись
Перенаправление привязки (binding redirect) В .NET Framework: настройка в файле конфигурации, которая подменяет запрошенную вызывающим кодом версию сборки фактически загружаемой

1. Сначала выводы

  • Совместимость бывает трёх слоёв: бинарная (работает без пересборки), исходного кода (работает после пересборки) и поведенческая (поведение не меняется). «Пересборка не нужна» не значит «безопасно»: смотреть нужно и на поведение.3
  • Для нативной DLL базовое правило: добавление экспорта безопасно; изменение или удаление существующего экспорта — критическое. Сигнатура функции, соглашение о вызовах и раскладка структуры — это и есть бинарный контракт.
  • Интерфейс COM неизменен (immutable) после публикации. Добавлять, удалять или переставлять методы после публикации нельзя по спецификации. Изменение оформляют как новый интерфейс с новым IID (IFoo → IFoo2).41
  • Клиенты VB6/VBA с ранним связыванием зашивают позиции в vtable при компиляции и от смены раскладки интерфейса ломаются чаще других.
  • В .NET «что в общедоступном API считается критическим изменением» опубликовано как правила изменений для совместимости Microsoft: к критическим относят не только удаление метода и смену сигнатуры, но и добавление virtual и даже переименование параметра.2
  • Семантическое версионирование — соглашение «критическое изменение → повышай MAJOR», но оно работает только после того, как вы объявили, что считать критическим.5 Таблицу из этой статьи можно взять как такое определение.
  • Если совместимость всё же приходится ломать, порядок такой: старое и новое рядом → период устаревания → инвентаризация вызывающего кода → снятие старого API. Сразу подменять нельзя.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 27, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Три слоя совместимости — кто и когда ломается

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

Слой Значение Что происходит при поломке Кто страдает в первую очередь
Бинарная совместимость Вызывающий код работает с новой DLL без пересборки При запуске не находится точка входа, во время выполнения — MissingMethodException, падения Старые EXE у заказчика, сторонние приложения, которые нельзя пересобрать
Совместимость исходного кода Вызывающий код работает, если его пересобрать Ошибки компиляции при следующей сборке Другая команда в компании, разработчики, у которых есть исходники
Поведенческая совместимость Заданное спецификацией поведение не меняется Результаты, время срабатывания или тип исключения меняются без какой-либо ошибки Конечные пользователи (и все, кто потом разбирает инцидент)

Эти три слоя удобнее мыслить вложенными.

[Поведенческая совместимость] поведение не меняется            ← самый внешний слой
  └─ [Совместимость исходного кода] работает после пересборки
       └─ [Бинарная совместимость] работает без пересборки   ← самый внутренний слой

Чем ближе слой к центру, тем жёстче условия; чем лучше соблюдён внутренний слой, тем меньше работы у вызывающего кода. Важно, что внутренний слой может быть цел, а внешний — уже сломан. Например, правка, которая меняет смысл возвращаемого значения существующей функции, сохраняет и бинарную совместимость, и совместимость исходного кода, ломая только поведенческую. Ошибки линковки и компиляции нет — это самая легко пропускаемая строка таблицы.

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

3. Таблица решений для нативных DLL (C/C++)

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

Изменение Бинарная совместимость Примечание
Добавление экспортируемой функции Не ломает Самый безопасный способ расширить API. Но если вы полагаетесь на неявные порядковые номера из .def-файла, добавление может перенумеровать уже существующие номера в зависимости от позиции; если хоть один клиент линкуется по номеру, явно зафиксируйте текущие номера и добавляйте новые в конец
Удаление или переименование экспортируемой функции Ломает Разрешение импорта не удаётся: ошибка при загрузке или из GetProcAddress
Смена сигнатуры существующей функции (добавление, удаление, смена типа аргументов, смена типа возвращаемого значения) Ломает Расходится передача через стек и регистры. С возвращаемым значением то же: смена целого (RAX) на число с плавающей точкой (XMM0) заставляет вызывающий код читать мусор по старому ABI. Ошибки может не быть — процесс просто начнёт вести себя непредсказуемо
Смена соглашения о вызовах (__cdecl__stdcall) Ломает (32-бит) На x86 меняется, кто чистит стек, и стек повреждается. У x64 соглашение одно, эти спецификаторы фактически игнорируются — строка относится только к 32-битным DLL
Смена порядкового номера экспорта (ordinal) Ломает при условии Клиент, который линкуется по номеру, начинает вызывать другую функцию. Если все линкуются только по имени, влияния нет
Добавление поля в структуру, которую выделяет вызывающий код Ломает Старый вызывающий код по-прежнему выделяет меньший буфер и передаёт его (смягчается соглашением cbSize, см. ниже)
Смена упаковки и выравнивания общедоступной структуры (#pragma pack, /Zp, смена средств сборки) Ломает Смещения существующих полей и общий размер меняются, даже если ни одно поле не трогали. cbSize от сдвинутой раскладки не спасает — упаковку явно фиксируют в общедоступном заголовке
Внутренние изменения структуры, которую выделяет и освобождает только сама DLL Не ломает Если наружу отдан только указатель (handle), внутреннее устройство можно менять свободно
Смена смысла возвращаемого значения или кода ошибки Не ломает (но ломает поведенческую совместимость) Линковка проходит, поведение меняется — паттерн, который находят позже всех
Добавление поля данных или виртуальной функции в класс C++, который экспортируют напрямую Ломает Меняется размер объекта или раскладка vtable. Добавление только невиртуальной функции-члена раскладку не меняет и существующих клиентов напрямую не ломает, но прямой экспорт класса C++ и так несовместим между компиляторами: если это решение приходится принимать каждый раз, ABI уже хрупкий

Таблица отвечает на вопрос «что мы собираемся менять». На практике чаще смотрят в обратную сторону: «этот симптом у заказчика — какая строка его вызвала». Ниже — как строки проявляются.

Симптом в поле Какую строку подозревать
При запуске диалог вроде «Не найдена точка входа процедуры…», приложение даже не стартует. NTSTATUS — 0xC0000139 (STATUS_ENTRYPOINT_NOT_FOUND, в оригинале “The procedure entry point %hs could not be located in the dynamic link library %hs.”)6 Удаление или переименование экспортируемой функции. В C++ смена сигнатуры меняет декорированное (mangled) имя, и получается тот же симптом «стало другим именем»
GetProcAddress возвращает NULL, приложение показывает своё сообщение об ошибке То же. У клиентов с отложенной или динамической загрузкой проявляется именно так
Сама DLL не находится, приложение не стартует. NTSTATUS — 0xC0000135 (STATUS_DLL_NOT_FOUND)6 Это не совместимость, а размещение и порядок поиска. Прежде чем смотреть таблицу, проверьте пути поиска DLL
Падение сразу после возврата из функции. В отладочной сборке (/RTCs или /RTC1) проверка времени выполнения ловит это как порчу указателя стека Смена соглашения о вызовах (32-бит). Microsoft прямо пишет, что порча указателя стека может быть следствием несовпадения соглашений о вызовах7. В release-сборке это не ловят, и падение происходит совсем в другом месте
Ошибки нет, но отдельные поля структуры содержат мусор. Реже — выход за границу буфера Добавление поля в структуру или смена упаковки и выравнивания. Без соглашения cbSize (раздел 3.1) это трудно отделить
У вызывающего кода на .NET — MissingMethodException Удаление, переименование или смена сигнатуры члена на стороне .NET (раздел 5)
Ошибок нет, но цифры в отчётах и сводках изменились Смена смысла возвращаемого значения или кода ошибки. Сломана только поведенческая совместимость; обнаруживают позже всех

Проектный вывод из таблицы не менялся десятилетиями: границу держите на C ABI (функции extern "C" и простые структуры), а расширяйте API добавлением функций. То же относится к нативной DLL из C#: экспортируемую поверхность из «Как вызвать Native AOT DLL на C# из C/C++» ведут по этой же таблице.

3.1 Соглашение cbSize — как в Win32 делают структуры расширяемыми

Классический ответ на «добавление поля в структуру ломает совместимость» — соглашение Win32 класть поле размера в начало структуры. Вызывающий код записывает в cbSize размер структуры, известный ему на момент компиляции, и передаёт её; DLL по этому размеру определяет, какое поколение структуры знает данный клиент.

typedef struct KS_CONFIG {
    DWORD cbSize;      // вызывающий код задаёт sizeof(KS_CONFIG)
    DWORD dwMode;
    DWORD dwTimeout;
    // новые поля всегда добавлять в конец
} KS_CONFIG;

// сторона DLL: по cbSize определяем поколение; для старого клиента — значение по умолчанию
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
    timeout = pConfig->dwTimeout;   // новый вызывающий код
} else {
    timeout = DEFAULT_TIMEOUT;      // старый вызывающий код
}

В условии стоит не sizeof(KS_CONFIG), а FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD), чтобы проверять «есть ли поколение вплоть до dwTimeout» по последнему известному полю. Если сравнивать с sizeof, то в момент, когда в конец добавят ещё одно поле, условие ужесточится, и клиенты, которые знают dwTimeout, но не знают нового поля, будут приняты за старое поколение. Проверка на каждое поле в таком виде избавляет от переписывания уже существующих условий при каждом расширении.

Именно так Windows API версионирует структуру NOTIFYICONDATA: официально задокументировано, что правильное значение cbSize сохраняет совместимость со старыми версиями Shell32.dll.8 Если положить cbSize в общедоступные структуры своей DLL с самой первой версии, последующие расширения уходят из «критического изменения» на безопасную сторону таблицы. При этом новые поля всегда добавляют в конец, а менять тип или порядок существующих полей по-прежнему нельзя. Ещё одно правило касается структур, которые DLL заполняет на выход: писать и инициализировать можно только в пределах полученного cbSize. Безусловная запись всего нового sizeof вылезает за меньший буфер старого клиента — и ту поломку, которую соглашение должно было предотвратить, устраивает уже сама DLL.

4. Неизменность интерфейса COM — после публикации менять нельзя

COM дал на эту задачу самый прямой ответ. По спецификации COM интерфейс подчиняется следующим правилам.

  • У интерфейса есть уникальный IID (идентификатор интерфейса).1
  • Интерфейс неизменен (immutable). После создания и публикации ни одну часть определения менять нельзя.1
  • Добавление или удаление метода либо смена семантики — это не «новая версия старого интерфейса», а новый интерфейс с другим IID.4

Строгость здесь не эстетика: сущность интерфейса COM — vtable, таблица указателей на функции, то есть бинарная раскладка. Клиент на C++ или VB6 при компиляции зашивает факт «слот 3 — это GetName», то есть позицию. Вставьте метод после публикации — и старый клиент вызовет другой метод, без какой-либо ошибки. Поэтому COM убрал саму операцию «изменить интерфейс» из спецификации и вместо неё дал такой способ расширяться.

// v1: уже опубликован. Больше не менять
[object, uuid(1111....)]
interface ICalc : IUnknown {
    HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};

// v2: новый интерфейс с новым IID. Расширяет ICalc наследованием
[object, uuid(2222....)]
interface ICalc2 : ICalc {
    HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};

В IDL выше uuid сокращён до 1111.... — это иллюстрация, так компилироваться не будет. На практике пишут полный GUID из средства создания GUID в Visual Studio (guidgen) или из команды uuidgen. Один и тот же GUID на двух интерфейсах лишает контракты различия, поэтому на каждый новый интерфейс GUID генерируют заново.

Класс-реализация (coclass) реализует и ICalc, и ICalc2; старые клиенты по-прежнему ходят в ICalc, новые запрашивают ICalc2 через QueryInterface. Официальная теория версионирования RPC/COM формулирует то же самое: новый интерфейс, унаследованный от старого, — аналог повышения минорной версии; смена существующего метода или типа требует совершенно нового интерфейса без наследования — аналог повышения MAJOR.9 Схема держится на том, что QueryInterface позволяет вызывающему коду во время выполнения безопасно узнать, что поддерживается. Замысел этого механизма разобран в «Что такое COM».

4.1 Роли CLSID, ProgID и IID

Думая о версиях COM, три вида идентификаторов лучше не смешивать.10

  • IID идентифицирует интерфейс (контракт). Сменился контракт — всегда новый IID.
  • CLSID идентифицирует класс-реализацию. Подменять реализацию, оставляя тот же CLSID, можно свободно, пока соблюдён контракт каждого опубликованного интерфейса.
  • ProgID — человекочитаемый псевдоним (KomuraSoft.Calc.1), по которому в реестре находят CLSID. Принято держать и версионированный ProgID, и не зависящий от версии ProgID (KomuraSoft.Calc), который всегда указывает на последнюю версию; второй сопоставляется с текущей через CurVer.10

Иначе говоря, «повышение версии реализации» — мир CLSID и ProgID, «смена контракта» — мир IID; смешивать их нельзя. Если регистрацию в реестре хочется обойти, этот вариант разобран в «Что такое Reg-Free COM».

4.2 Почему клиенты VB6/VBA ломаются особенно легко

Когда VB6 или VBA используют COM-компонент через ссылку в проекте (раннее связывание), они при компиляции читают библиотеку типов и по ней разрешают вызовы. Раннее связывание — рекомендуемая форма: работают IntelliSense и проверка типов, выполнение быстрее11, — но плата за это — жёсткая привязка к раскладке библиотеки типов. Смена vtable интерфейса, разумеется, ломает вызовы; но даже смена одних только определений в библиотеке типов проявляется как «открыли проект — ссылка сломана» или как ошибки времени выполнения 430/438.

Поэтому в компонентах, которые вызывают VB6, VBA или макросы Excel, правило неизменности интерфейса нужно соблюдать максимально строго. У библиотеки типов тоже есть версия (major.minor); её повышают, когда контракт расширяют. Как генерировать библиотеку типов при публикации типизированного кода .NET для VBA, разобрано в «Как использовать DLL на .NET 8 из VBA с типизацией — публикация COM и TLB через dscom». Клиенты с поздним связыванием, которые ходят только через CreateObject, разрешают вызовы по имени и к смене раскладки устойчивее, но от смены смысла метода (поведенческой совместимости) страдают так же.

5. Совместимость сборок .NET — оценка по официальным правилам

В .NET опубликован набор «правил изменений для совместимости», которым сама Microsoft пользуется при разработке библиотек .NET: каждое изменение классифицируют как разрешённое (✔️), запрещённое (❌) или требующее оценки (❓).2 В документации прямо сказано, что этот набор можно взять как критерий для собственных библиотек, поэтому ниже — основные строки.

Изменение общедоступного API Вердикт Примечание
Добавление метода, типа или члена ✔️ В целом безопасно Осторожно с добавлением, которое меняет разрешение существующих перегрузок. Добавление поля экземпляра в общедоступную структуру — исключение: меняются размер и раскладка, ломаются интероперабельность и потребители unsafe-кода
Удаление или переименование общедоступного типа или члена ❌ Критическое Ломается во время выполнения с MissingMethodException и подобными
Смена сигнатуры (добавление, удаление, порядок, тип аргументов, тип возвращаемого значения) ❌ Критическое Ломает и бинарную совместимость, и совместимость исходного кода
Переименование параметра ❌ Критическое Ломает именованные аргументы C# и позднее связывание VB. Легко пропустить
Добавление virtual к члену ❌ Критическое Классическая ловушка: «это же просто добавление». Может разойтись IL вызова (call/callvirt)
Снятие virtual, превращение виртуального члена в abstract ❌ Критическое Ломает переопределения в производных классах
Добавление абстрактного члена в незапечатанный общедоступный тип ❌ Критическое У существующих производных классов нет реализации
Запечатывание (sealed) типа ❌ Критическое Существующие производные классы перестают компилироваться
Добавление члена в интерфейс ❓ Нужна оценка Реализация по умолчанию (DIM) позволяет не ломать существующие классы-реализации, но условий много (ниже)
Смена константы или значения enum, переименование или удаление члена enum ❌ Критическое Значение зашивается в вызывающий код при компиляции
Начать бросать более производное исключение ✔️ Разрешено Существующие catch продолжают срабатывать
Бросать исключение нового вида на существующем пути кода ❌ Критическое Бросать его только для нового значения параметра — можно

Единственная строка ❓ (нужна оценка) — «добавление члена в интерфейс»; на практике именно она вызывает больше всего сомнений. Реализация по умолчанию (DIM: Default Interface Members) позволяет добавить член, не оставляя существующие классы-реализации без реализации, но официальные правила называют такие условия.2

  • Минимальные требования у потребителей поднимаются до .NET Core 3.0 / C# 8.0. DIM появились в этих версиях: добавили реализацию по умолчанию — и все, кто сидит на более старой среде выполнения, остаются за бортом. .NET Framework сюда не входит, поэтому если в библиотеке остался хотя бы один клиент на .NET Framework, этим послаблением пользоваться нельзя.
  • Есть языки без поддержки DIM. .NET вызывают из нескольких языков; если интерфейс реализован не на C#, на реализацию по умолчанию опираться нельзя.
  • Среда выполнения не всегда может выбрать, какую реализацию по умолчанию вызвать. Когда в схеме участвует несколько интерфейсов, разрешение DIM может стать неоднозначным.
  • Начиная с C# 13, добавление члена экземпляра по умолчанию в интерфейс, который реализует ref struct, — критическое изменение исходного кода. ref struct нельзя ни упаковать, ни привести к типу интерфейса, поэтому откатиться на реализацию по умолчанию нельзя: члены экземпляра нужно реализовывать явно.

При этом добавление статических неабстрактных и невиртуальных членов разрешено.2 Если «хочется добавить член, но у потребителей ещё есть .NET Framework», безопаснее не трогать интерфейс, а по той же логике, что в разделе 4 про COM, добавить новый интерфейс — или сначала проверить, не закрывают ли задачу методы расширения.

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

5.1 Строгое имя и три номера версии

У сборки .NET несколько номеров версии, и роли у них разные.12

  • AssemblyVersion: единственная версия, по которой среда выполнения идентифицирует и загружает сборку. Для сборок со строгим именем CLR .NET Framework требует точного совпадения, поэтому каждое повышение заставляет вызывающий код добавлять перенаправление привязки (.NET / .NET Core, напротив, автоматически принимают более высокую версию). Чтобы сократить число перенаправлений, официальные рекомендации предлагают отражать в AssemblyVersion только основную версию.
  • FileVersion (AssemblyFileVersion): виден только в свойствах файла в Проводнике и на поведение среды выполнения не влияет. Рекомендуемое место для номера сборки CI.
  • InformationalVersion: произвольная строка для человека. Сюда пишут версию пакета в формате semver или хеш коммита исходников.

На практике удобна трёхслойная схема: «совместимость объявлять версией пакета или продукта (semver), в AssemblyVersion отражать только MAJOR, сборки сопровождать через FileVersion».

6. Как назначать номера версий — semver работает только вместе с определением

Суть семантического версионирования (semver) укладывается в три строки: повышайте MAJOR при несовместимом изменении, MINOR при обратно совместимом добавлении функциональности и PATCH при обратно совместимом исправлении ошибки.5

Легко пропустить, что первое требование спецификации semver — программное обеспечение, которое использует semver, обязано объявить общедоступный API.5 Пока не объявлено, что считается общедоступным API, критерия «несовместимого изменения» нет, и решение повышать MAJOR зависит от настроения конкретного человека. Там, где semver «не работает», чаще пропущено именно это объявление, а не способ нумерации.

Для DLL, которую разносят внутри компании, реалистичная схема такая.

  1. Объявите границы общедоступного API — для нативной DLL это экспортируемые функции и общедоступные заголовки, для COM — IDL и библиотека типов, для .NET — общедоступные типы и члены. Прямо напишите: «всё остальное — внутренняя реализация и может измениться без предупреждения».
  2. Примите определение критического изменения — положите таблицы из разделов 3 и 5 этой статьи и правила изменений .NET2 в репозиторий как «собственное определение».
  3. Автоматизируйте проверку — для .NET инструменты Package Validation / ApiCompat механически сверяют бинарную совместимость с предыдущей версией.13 На ревью пропадает «наверное, нормально».
  4. Заведите в заметках к релизу графу совместимости — каждый раз явно пишите одно из трёх: «пересборка не нужна / пересборка рекомендуется / есть критическое изменение». Это способ ответить на вопрос из начала статьи — «достаточно ли подменить?» — письменно, ещё до того, как его зададут.

7. Что делать, когда совместимость всё же приходится ломать

Если изменение, которое таблица пометила как критическое, всё равно необходимо, идите не через подмену, а через параллельную поставку.

  1. Поставляйте старое и новое рядом — для COM это добавление IFoo2 с сохранением IFoo (раздел 4). Для нативной DLL — новая функция (FooEx) или соседство новой DLL с другим именем. Для .NET — выпуск как нового пакета с повышенным MAJOR, при этом старый MAJOR продолжает получать только исправления ошибок.
  2. Задайте период устаревания — в .NET атрибут [Obsolete] даёт предупреждение при компиляции. Для нативного кода и COM объявляйте это комментарием в заголовке и в заметках к релизу, с конкретной датой снятия. Суть — назначить дату, а не расплывчатое «когда-нибудь уберём».
  3. Проведите инвентаризацию вызывающего кода — соберите список тех, кто ещё вызывает старый API: поиск по внутренним исходникам, записи о рассылке установщиков, для COM — ссылки в реестре. Если найдётся двоичный файл, который нельзя пересобрать (инструмент ушедшего сотрудника, стороннее приложение), либо продлите жизнь старого API именно для этого случая, либо перекиньте мост обёрткой.
  4. Удалите старый API — только после того, как инвентаризация подтвердила, что вызывающих не осталось, удалите его и повысьте MAJOR.

Эта процедура затратна. Поэтому, как ни парадоксально, самое сильное средство совместимости — с первого релиза проектировать небольшой API с оглядкой на таблицу (то, что вы не опубликовали, обязательств по совместимости не несёт).

8. Кратко

  • Совместимость смотрят в трёх слоях: бинарном, исходного кода и поведенческом. Даже без пересборки поведение может измениться.3
  • Для нативных DLL: добавление безопасно; смена существующего экспорта, сигнатуры или раскладки структуры — критическая. В структуры кладите cbSize, чтобы оставить место для расширения.8
  • Интерфейс COM неизменен после публикации. Изменения добавляйте как новый интерфейс с новым IID (IFoo2) и различайте их через QueryInterface.149 При клиентах VB6/VBA с ранним связыванием соблюдайте это особенно строго.
  • .NET можно оценивать механически по официальным правилам изменений для совместимости. Следите за «на вид безопасными» правками — виртуализацией, переименованием параметров, запечатыванием типа, — которые отнесены к критическим.2
  • Реалистична трёхслойная схема: в AssemblyVersion только MAJOR, сборки сопровождать FileVersion, совместимость объявлять semver.12
  • Semver работает только после объявления общедоступного API и определения критического изменения.5 Возьмите таблицу как это определение и проверяйте её автоматически через Package Validation и аналоги.13
  • Когда что-то приходится ломать: параллельная поставка → период устаревания → инвентаризация → удаление. Отказ от одномоментной подмены — вот что защищает старый EXE у заказчика.

Статьи по теме

Смежные направления консультаций

KomuraSoft LLC занимается проектированием совместимости DLL, COM-компонентов и библиотек .NET, на которые ссылаются другие системы: инвентаризацией общедоступного API и политикой версий, а также проектированием и реализацией расширений, которые не ломают существующих клиентов (схема IFoo2, параллельная поставка).

Справочные материалы

  1. Microsoft Learn, Interface Design Rules. О том, что интерфейсы объекта COM должны иметь уникальный IID и что после создания и публикации ни одну часть определения менять нельзя (неизменность).  2 3 4 5

  2. Microsoft Learn, Change rules for compatibility (.NET). О том, что изменения API .NET классифицируют как разрешённые, запрещённые или требующие оценки; что удаление или переименование общедоступных типов и членов, смена сигнатуры, переименование параметров, добавление и снятие virtual, запечатывание, смена значений констант и enum запрещены (критические); что добавление члена в интерфейс требует оценки; что авторы библиотек могут использовать эти правила как критерий для своей библиотеки.  2 3 4 5 6 7

  3. Microsoft Learn, Breaking changes (.NET library guidance). О том, что критические изменения делят на ломающие исходный код, поведение и бинарную совместимость; при поломке бинарной совместимости сборка, скомпилированная против старой версии, падает во время выполнения с MissingMethodException и подобными.  2 3

  4. Microsoft Learn, Interface Pointers and Interfaces. О том, что интерфейс COM неизменен и что добавление или удаление метода либо смена семантики означают создание нового интерфейса, а не новой версии старого; IID однозначно задаёт контракт.  2 3

  5. semver.org, Semantic Versioning 2.0.0. О повышении MAJOR при несовместимых изменениях API, MINOR при обратно совместимом добавлении функциональности и PATCH при обратно совместимом исправлении ошибок; о том, что программное обеспечение, использующее semver, обязано объявить общедоступный API; о том, что обратно несовместимое изменение общедоступного API всегда требует повышения MAJOR.  2 3 4

  6. Microsoft Learn, MS-ERREF 2.3.1 NTSTATUS Values. О том, что STATUS_ENTRYPOINT_NOT_FOUND (0xC0000139) соответствует тексту “The procedure entry point %hs could not be located in the dynamic link library %hs.”, а STATUS_DLL_NOT_FOUND (0xC0000135) — “This application has failed to start because %hs was not found.”  2

  7. Microsoft Learn, /RTC (Run-time error checks). О том, что /RTCs/RTC1) проверяет указатель стека и обнаруживает его порчу; что порча может быть следствием несовпадения соглашений о вызовах (например, функцию, экспортированную как __stdcall, вызывают через указатель __cdecl); что /RTC нельзя использовать в release-сборке с оптимизацией. 

  8. Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h). Об установке размера структуры в члене cbSize, о том, что структура расширялась от поколения к поколению, и о том, что правильное значение cbSize позволяет сохранять совместимость со старыми версиями Shell32.dll.  2

  9. Microsoft Learn, The Versioning Theory for RPC and COM. О том, что для расширения функциональности в COM лучше всего создавать новый интерфейс; новый интерфейс, унаследованный от старого, соответствует минорному повышению версии; смена существующих методов или типов требует совершенно нового интерфейса без наследования; QueryInterface позволяет вызывающему коду проверить, что поддерживается.  2

  10. Microsoft Learn, COM Registry Keys. О том, что CLSID — GUID, идентифицирующий класс COM; ProgID — человекочитаемая строка, сопоставленная с CLSID без гарантии уникальности; не зависящий от версии ProgID сопоставляется с последней версией класса через CurVer; ключ Interface регистрирует IID.  2

  11. Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project). О том, что в VBA рекомендуется раннее связывание через ссылку в проекте; что позднее связывание (CreateObject/ProgID) не показывает члены при написании кода и работает медленнее; что для раннего связывания нужна ссылка на целевую библиотеку объектов. 

  12. Microsoft Learn, Versioning (.NET library guidance). О том, что AssemblyVersion используется средой выполнения для загрузки и для сборок со строгим именем в .NET Framework требует точного совпадения; что в AssemblyVersion предлагается включать только основную версию; что FileVersion предназначен для отображения в Windows и не влияет на поведение во время выполнения; что InformationalVersion служит для записи дополнительной информации о версии; что для версий пакетов NuGet рекомендуется semver 2.0.0.  2

  13. Microsoft Learn, NuGet package compatibility rules. О необходимости избегать изменений, ломающих бинарную совместимость; о том, что Package Validation и ApiCompat автоматически обнаруживают совместимость с базовой версией; о том, что AssemblyVersion нельзя понижать между релизами.  2

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Как безопасно менять унаследованное бизнес-приложение без тестов — характеризационные тесты и рефакторинг на практике

На примерах C# разбираем, как безопасно менять бизнес-приложение без тестов: как зафиксировать текущее поведение характеризационным тесто...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Если в DLL только добавить функцию, нужно ли пересобирать вызывающий код?
Как правило, нет: если вы только добавляете экспортируемую функцию, существующий вызывающий код продолжает работать. Разрешение импорта по-прежнему проходит, пока не меняются имя, сигнатура, соглашение о вызовах и порядковый номер экспорта уже существующих функций. Но если добавить поле в структуру, которую выделяет и передаёт вызывающий код, или изменить смысл возвращаемого значения либо кода ошибки существующей функции, поведенческая совместимость может нарушиться даже без пересборки. Базовое правило: добавление функции безопасно, изменение существующей сигнатуры — критическое.
Почему в интерфейс COM нельзя добавить метод после публикации?
Потому что по спецификации COM опубликованный интерфейс неизменен (immutable). Интерфейс — это контракт бинарной раскладки: vtable, упорядоченный список указателей на функции. Вставка, удаление или перестановка методов приводят к тому, что старый двоичный файл вызывает другой метод по позиции слота, зашитой при компиляции. Добавление в конец не сдвигает существующие слоты, но создаёт другую аварию: новый клиент может взять старый компонент, решив, что «добавленный» метод уже есть, и вызвать слот, которого там нет. Поэтому добавлять методы, оставляя тот же IID, тоже нельзя. Нужна новая функциональность — добавляют новый интерфейс с новым IID (IFoo2) и оставляют существующий IFoo как есть. Вызывающий код через QueryInterface безопасно узнаёт, какой из интерфейсов — старый или новый — реализован.
Как в .NET развести AssemblyVersion, FileVersion и InformationalVersion?
AssemblyVersion — единственная версия, по которой среда выполнения идентифицирует и загружает сборку. Для сборок со строгим именем CLR .NET Framework требует точного совпадения, поэтому каждое повышение заставляет вызывающий код добавлять перенаправление привязки (binding redirect). Поэтому официальные рекомендации предлагают отражать в AssemblyVersion только основную (major) версию. FileVersion виден только в свойствах файла в Проводнике и на поведение среды выполнения не влияет: туда удобно писать номер сборки CI. InformationalVersion — произвольная строка для человека: версия в формате semver или хеш коммита.
Решает ли семантическое версионирование (semver) проблемы совместимости само по себе?
Нет. Semver — это соглашение повышать MAJOR, если изменение несовместимо с предыдущими клиентами, но оно требует заранее объявить, что считается общедоступным API и что — критическим изменением. Если проставлять номера без такого определения, решение каждый раз зависит от человека, и схема не работает. Semver обретает смысл, когда вы принимаете таблицу решений из этой статьи (для нативных DLL) или официальные правила совместимости Microsoft (для .NET) как собственное определение критического изменения и встраиваете его в процесс релиза.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог