Батл анкл Боба и Муратори

November 13, 2025

Введение

Я тут наткнулся на эпичный батл между Робертом Мартином (он же «Дядя Боб») и Кейси Муратори, который в итоге свёлся к полиморфизму против свитчей (но с неожиданным поворотом).

Батл заканчивается фразой анкл Боба «Я думаю, нам стоит на этом остановиться в нашем споре и предоставить нашей аудитории вынести окончательный вердикт» (I believe we should let our disagreement stand at this point and let our audience be the final judge).

И мой вердикт таков: Муратори всю дорогу вёл по очкам и победил нокаутом, разнеся в пух и прах SRP, OCP и DIP. Правда, сделал он это в очень узком контексте — API, предназначенного для реализации внешними поставщиками (SPI в мире Java). Соответственно для разработчиков прикладных приложений (коих в мире большинство) это не имеет никакого практического значения. Тем не менее, ознакомится с ним полезно всем, на мой взгляд.

Так как батл у них получился невероятно длинный (я читал часа четыре) и довольно нудный, я советую прочитать только мякотку, начинающуюся во втором файле со слов «I’m not even talking about machine-cycles, I was just focusing on programmer-cycles». А для того чтобы сделать позицию Муратори (заслуживающую внимания, на мой взгляд) более доступной русскоязычной аудитории, я решил сделать её близкий к дословному перевод.

Перевод разноса SRP/OCP/DIP

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

Во-первых, каждая операция представлена функцией 2+d раза (один раз в пользовательском пространстве, один раз в ядре, затем в d драйверах). Таким образом, для O операций у нас будет (2+d)*O фрагментов кода.

На стороне приложения [прим. пер.: по-видимому речь идёт об API ОС, используемым приложением] у нас есть:

int read(char* name, size_t offset, size_t n, char* buf);
int write(char* name, size_t offset, size_t n, char* buf);

Они работают в пользовательском пространстве, а затем они переходят к версиям ring-0 [прим. пер.: внутри ядра ОС], которые выглядят примерно так (если я правильно понимаю):

int read_internal(char* name, size_t offset, size_t n, char* buf)
{
    // Здесь идут преобразования адресов и проверка границ для "name" и "buf"

    int Error = DEVICE_NOT_FOUND;
    raw_device *Dev = find_device(name);
    if(Dev)
    {
    	Dev->read(offset, n, buf);
    }

    return Error;
}

где raw_device выглядит примерно так:

class raw_device {
public:
	virtual void read(size_t offset, size_t n, char* buf) = 0;
	virtual void write(size_t offset, size_t n, char* buf) = 0;
	virtual char* get_name() = 0; // возвращает имя устройства
}

Для сравнения давайте посмотрим, как бы выглядело решение на основе перечислений. В этой ситуации я не вижу никаких практических ограничений для того, чтобы всё свести к числам [прим. пер.: а не типам/классам], поэтому я бы сделал это буквально для всего — и для устройств, и для операций.

// Некоторые люди предпочитают пространства имен и их аналоги, так что, если вам это нравится, представьте,
// что они обернуты для именования так, как вам больше нравится

enum raw_device_operation : u32
{
	RIO_none,

	RIO_read,
	RIO_write,
	RIO_get_name,

	// полностью опционально - я бы сделал это, но, с другой стороны,
	// может и не стал бы, если эти драйверы работают в ring-0 и мы им не особо доверяем
	RIO_private = 0x80000000,

};

struct raw_device_id
{
	u32 ID;
};

struct raw_device_request
{
	size_t Offset;
	size_t Size;
	void *Buffer;
	raw_device_operation OP;
	raw_device_id Device;

    // В ОС чтобы-то ни было я обычно хочу выровнять до размера кэш-линии на случай многопоточности
	u64 Reserved64[4];
};

struct raw_device_result
{
    u32 error_code;

    // То же, что и выше - возможно, это перебор, но мне не нравятся вещи, не выровненные по кэшу,
    // так что оставлю в силу привычки.
    u32 Reserved32;
    u64 Reserved64[7];
};

Далее, я не люблю vtables или языково-специфичные объектные механизмы где бы то ни было, так как считаю, что ими труднее управлять, поэтому я предпочёл бы

typedef void raw_device_handler(u32 Instance, raw_device_request *Packet, raw_device_result *Result);
struct raw_device
{
	raw_device_handler *Handler;
};

вместо версии с классом. Но единственная реальная причина, по которой я должен это написать, конечно же, заключается в том, что C++ отстой и вы не можете сказать «Я бы хотел, чтобы этот класс содержал указатели на функции, вместо указателя на таблицу указателей на функции, пожалуйста.» В противном случае я бы не имел ничего против версии с классом. Таким образом, на практике я бы предпочёл версию с прямыми указателями. Но для целей этого обсуждения (поскольку сейчас нас интересуют только циклы программиста) вы можете игнорировать мой struct raw_device. Соответственно, мы можем просто предположить, что он написан в виде класса, как у вас, поскольку я не вижу никакой пользы для программиста от struct-версии в этом случае:

class raw_device
{
public:
	void Handler(raw_device_request *Packet, raw_device_result *Result);
};

И так как пользователи [прим. пер.: разработчики, использующие IO API] никогда не видят raw_device — они просто используют 32-битный дескриптор raw_device_id — оставить это в виде класса вполне допустимо.

А когда кто-то реализует драйвер устройства, они на самом деле реализуют одну функцию:

void raw_device::Handler(raw_device_request *Packet, raw_device_result *Result)
{
	switch(Packet->Op)
	{
		case RIO_read:
		// etc.

		case RIO_write:
		// etc.

		case RIO_get_name:
		// etc.

		default:
		// write error Result
	}
}

Очевидно, что версия на стороне пользователя может быть такой же, как и ваша:

int read(char* name, size_t offset, size_t n, char* buf);
int write(char* name, size_t offset, size_t n, char* buf);

Однако я бы рекомендовал, чтобы она в обоих случаях была похожа на:

raw_device_id lookup_device(char* name);
int read(raw_device_id device, size_t offset, size_t n, char* buf);
int write(raw_device_id device, size_t offset, size_t n, char* buf);

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

В любом случае, я думаю, что эта реализация экономит циклы программиста в процессе разработки ОС. Возможно, очень много циклов, по сравнению с той, которую я понимаю как предпочитаемую методом «Чистого кода» (и также являющейся той, что вы написали выше). Опять же, насколько я понимаю, причиной того, что ваша версия выглядит именно так, как она выглядит, являются две идеи проектирования, которые вы часто упоминаете в книгах и лекциях:

  • Вы предпочитаете иметь один класс для каждого типа вещи (в нашем случае драйвера), с одной виртуальной функцией-членом на каждую операцию, которую эта вещь выполняет (в нашем случае чтение, запись и получение имени).
  • Перестановки операций не должны проходить через функции [прим. пер.: не нашёл способа перевести это, не коверкая смысла, но далее по тексту становится очевидно о чём речь] — поэтому передача перечисления в функцию, которое говорит, что ей делать — это плохо. У вас должна быть одна функция на каждую вещь, которую необходимо сделать. Я видел, что это упоминалось несколько раз — не только для операторов switch, но и когда вы говорите о функциях с «if» операторами, которые в зависимости от параметров изменяют поведение функции. Вы говорите, что такие функции должны быть переписаны как две функции.

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

Я не знаю, является ли «абстрагирование файлового ввода-вывода» наилучшим примером, но это был первый пример, который вы привели, и он случайно достаточно хорошо иллюстрирует разницу в наших дизайнах, так что для меня он подходит. И вот почему я думаю, что дизайн на основе перечислений экономит циклы программиста(ов):

  • В большинстве систем мы не знаем все функции, которые нам предстоит реализовать заранее. При работе через жёсткую границу, такую как драйвер, использование кодов операций вместо виртуальных вызовов функций позволяет нам добавлять функции динамически без перекомпиляции всех наших драйверов. Если мы хотим, например, добавить новую функцию наподобие «trim» (как это произошло с реальными протоколами ввода-вывода, когда появились SSD) и если мы просто передаём коды операций драйверу, нам не нужно ничего перекомпилировать. Все старые драйверы просто игнорируют этот код, в то время как новые драйверы реагируют на него, что и требуется. В случае с классом же либо все драйверы должны быть перекомпилированы, либо мы должны написать утилитный класс, который прикрывает новую функцию заглушкой, и обернуть все старые драйверы в этот утилитный класс. Если мы этого не сделаем, таблицы виртуальных функций для старых драйверов будут неправильного размера, поэтому мы не сможем их использовать.
  • Любая современная система должна учитывать многопоточную работу, но это особенно верно для операционной системы. Благодаря протоколу, основанному на структурах с кодом операции, мы можем тривиально буферизовать операции в io rings или других промежуточных структурах без написания какого-либо нового кода. Вся система остаётся той же самой, и нам нужно только лишь добавить новую логику буферизации. В системе с классами у нас нет способа представить операции с помощью данных, поэтому мы должны ввести формат данных для хранения вызовов — который, конечно же, в итоге будет выглядеть практически так же, как код для версии без классов, поэтому вам придётся написать мою версию, а также версию с классами. Кстати, это, похоже, происходит почти во всех OOP-системах, которые я вижу, потому что в итоге им нужно делать сериализацию или что-то подобное, и поэтому им приходится писать enum-версию, а также их версию, но они, похоже, не осознаю́т, сколько времени они таким образом тратят впустую!
  • Если в какой-то момент мы решим, что пользователи должны иметь возможность выполнять многопоточные/пакетные операции ввода-вывода, с версией на основе перечислений не потребуется никаких изменений во внутренней части и драйверах. Всё, что нам нужно сделать в этом случе — это добавить какой угодно интерфейс на стороне пользователя для этой задача (предположительно, это будет кольцевой буфер, в который они будут записывать), и всё «просто работает», без необходимости какой-либо трансляции [прим. пер.: без маппинга], потому что внутренности уже работают с данными вместо таблиц виртуальных функций/вызовов функций.
  • Если мы захотим разрешить третьим сторонам иметь приватные каналы связи для их устройств, где пользовательский код может выполнять IO-операции, определённые только для этих устройств, то у нас нет способа сделать это, если мы придерживаемся идеи, что каждая операция должна быть представлена собственной виртуальной функцией. Это требует реализации полноценного обходного пути, который должен быть специально разработан и развёрнут (deployed) каждым вендором отдельно, потому что мы не знаем, сколько функций потребуется каждому устройству или как они будут называться. С enum-версией это тривиально. Просто зарезервируйте половину диапазона кодов операций для приватных операций и позвольте любому значению в этом диапазоне просто проходить к устройству. Теперь вендор поставляет таблицу приватных операций конечным пользователям (или SDK с вызовами функций, транслирующих вызовы в коды операций из этого перечисления, если хотите), и всё. Действительно ли вы хотите делать это в ОС — спорный вопрос, так как в этом случае вы больше доверяете драйверу, но суть в том, что enum-архитектура делает это тривиальным. А сто́ит ли это разрешать по соображениям безопасности — другой вопрос, зависящий от типа ОС, в том числе.

Также сто́ит упомянуть, что всё вышеперечисленное действительно произошло с дизайном подсистем ввода-вывода в операционных системах. Таким образом, ни один из этих пунктов не является гипотетическим и практически невероятным или специально подобранным в интересах enum-дизайна. По сути, это всего лишь конкретные события, которые произошли за последние ~20 лет эволюции подсистемы ввода-вывода операционной системы в широком смысле.

Итак, что же здесь на самом деле происходит? Проблема, на мой взгляд, заключается в том, что программы имеют пути между вещами [прим. пер.: как я понимаю, путь — это цепочка объектов от объекта, доступного пользователю, до объекта, содержащего, функцию, которую пользователь хочет вызвать]. У меня есть одно место, например, в пользовательском коде, где кто-то хочет что-то сделать. И у меня есть другое место, возможно очень далёкое, например, в драйвере, где это что-то должно быть выполнено. Если мы следуем стилю классов с небольшими виртуальными функциями, которые делают одну вещь, мы требуем, чтобы весь путь [прим. пер.: классы всех промежуточных объектов] между двумя этими местами был расширен для включения каждой возможной операции, которую система должна выполнять. Но зачем? Зачем мы дублируем эти функции повсюду? Почему мы не можем просто сделать путь узким и использовать перечисления, чтобы любой [промежуточный объект], который хочет отреагировать на конкретную операцию, мог это сделать, а те, что не хотят — не были бы обязаны этого делать?

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

И ещё один способ сказать то же самое: если вы можете делать несколько вещей по одному и тому же шаблону, почему бы не делать это? В чём польза от умножения кода?

Здесь сто́ит упомянуть, что я не столько критикую ООП в целом, сколько его современную интерпретацию (как она обычно практикуется сегодня и как она представлена в Java, C++ и в «Чистом коде» тоже). Я думаю, что описанный мной enum-дизайн, по крайней мере, отчасти покажется знакомым первым ООП-программистам и адвокатам, вроде Алана Кея, так как этот дизайн больше похож на передачу сообщений, о которой они все говорили. И хотя я не думаю, что программистам выгодно думать в терминах коллекций объектов, передающих сообщения, я менее критично отношусь к этой идее, чем к идее виртуальных методов. И я действительно довольно часто использую вещи, похожие на передачу сообщений (как я сделал в этом случае с загружаемыми драйверами).

Более того, в большинстве систем я бы также искал способы сделать свёртку по типам устройств. Но в нашем конкретном случае, поскольку мы говорим о системе, для которой мы заранее условились что хотим, чтобы драйверы загружались отдельно, то единственное место, где мы можем это сделать — внутри драйвера устройства. В результате мы фактически не говорим об этой части архитектуры [прим. пер.: о свёртке кода для всех типов устройств в один класс, если я правильно понял]. Но если бы говорили — я бы искал возможности сделать это. Так что если, например, несколько устройств имели бы похожие интерфейсы, которые отличались лишь несколькими аспектами, я бы сделал ещё одну не-«Чистокодовую» вещь, заключающуюся в том, чтобы свернуть их в один драйвер с «if’ами» по типу устройств, где это уместно.

Я остановлюсь на этом, поскольку уже упомянул много вещей, но, надеюсь, это даёт общее представление.

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


Далее анкл Боб попытался вывернуть всё так, что оба дизайна являются одними и теми же яйцами под разными углами, но Муратори на убедительно, мой взгляд, отбился от этой попытки.

После чего анкл Боб свернул дискуссию и призвал аудиторию вынести вердикт, так и не признав поражения.

Заключение

Как я писал во введении, на мой взгляд, аргументы Муратори в пользу enum-дизайна выглядят более чем убедительно. Однако я не пишу подсистему ввода-вывода ОС и у меня не появляются новые драйверы еженедельно. И даже ежегодно не появляются.

В общем, имхо, для бакендера enum-дизайн теоретически интересен, но практически бесполезен. Хотя, может я просто не познал его дзен.