Интернет - первое, что построило человечество и чего человечество не понимает, крупнейший эксперимент в анархии за всю нашу историю .
-- Эрик Шмидт
Контекст
Очень часто кто-то где-нибудь на каком-нибудь форуме жалуется на нехватку инкапсуляции и изоляции в языке программирования C. Это происходит с такой регулярностью, что я намерен раз и навсегда разрушить этот миф. Благодаря этому когда в следующий раз кто-то будет делать подобные заявления, я смогу просто дать ссылку на эту страницу, а не писать объяснение заново.
Нужно сказать, что C – это старый язык, в котором не хватает множества современных возможностей. Но чего в нём хватает, так это инкапсуляции и изоляции.
Миф: поля структур не могут быть скрытыми
Давайте взглянем на определение класса, имеющего члены private
; в конце-концов, ведь именно так возникает изоляция, правда? Если бы все поля были публичными, то это был бы просто C, но с наследованием1.
Это сарказм, так что расслабьтесь.
class StringBuilder {
private String payload;
public void Append(String snippet) {
payload = payload + snippet;
}
};
Любая попытка вызывающей стороны получить доступ к полю payload
приведёт к ошибке компиляции. Довольно удобно. Вы понимаете, когда это может быть полезно.
В языке программирования C нет классов, но есть struct
, которые выглядят так:
struct StringBuilder {
char *payload;
};
void Append(struct StringBuilder *obj, const char *snippet);
В нём нет модификаторов доступа; все поля в структуре публичны. Именно поэтому кто-то всегда жалуется на отсутствие в C инкапсуляции или изоляции: всё в структуре видно всем и всегда, даже вызывающей стороне функции Append()
.
Реальность
Но эта жалоба не всегда справедлива. Хотя вы конечно можете скинуть всё в один файл исходного кода и на этом закончить, чаще всего код разделяется на разные файлы и модули.
Когда код находится в разных файлах исходного кода, они инкапсулированы в модуль. Каждый «модуль» в C состоит из файла интерфейса, который вызывающие могут использовать для вызова функций, находящихся в файле реализации.
Файл интерфейса, называемый файлом заголовка (с расширением .h
) - это контракт, сообщающий пользователю модуля, какие функции и типы нужны для использования реализации, достаточно часто называемой файлом исходного кода. После компиляции у вас получается скомпилированная реализация, и таким образом вы можете не иметь доступа к исходному коду Append()
.
И… та-да!
Вызывающие программы обязаны использовать файл заголовка для выполнения законных (по стандартам C) вызовов Append()
; в конечном итоге, реализация может быть доступна только в скомпилированном виде, а не в виде исходного кода.
Поэтому в заголовке мы делаем следующее:
typedef struct StringBuilder StringBuilder;
void Append(StringBuilder *obj, const char *snippet);
А в реализации следующее:
struct StringBuilder {
char *payload;
};
void Append(StringBuilder *obj, const char *snippet)
{
...
}
Вот и всё!
Теперь любой код, использующий структуру типа StringBuilder
, сможет использовать всё нужное ему для создания строки, но никогда не увидит строки внутри неё.
Да он даже не сможет выполнить malloc()
для своего собственного экземпляра StringBuilder
, потому что скрыт даже размер StringBuilder
. Ему придётся использовать функции создания и удаления, предоставленные в реализации и указанные в интерфейсе.
Но и это ещё не всё…
Итак, теперь у нас есть возможность создать экземпляр объекта, все поля которого скрыты от любой вызывающей стороны. Одновременно вы запретили всем вызывающим сторонам вмешиваться в поля своего объекта – весь доступ к объекту защищён функциями в реализации (как указано в заголовке).
У нас нет наследования, зато есть одна очень важная характеристика: из этого класса можно создавать объекты и использовать их, даже в Python.
Или в PHP.
И даже в Ruby.
Это будет работать с большинством реализаций Lisp.
Их можно вызывать из Java.
На самом деле, я не думаю, что какой-то из распространённых языков программирования не сможет использовать этот объект. Во многих случаях программисту на этом языке даже не придётся особо трудиться, чтобы использовать этот класс2.
См. swig
В моих Makefiles
уже есть правила автоматической генерации интерфейса, чтобы написанный в этой манере код на C можно было вызывать из приложений для Android.
Ладно, вы всё поняли, пора заканчивать
Вот так можно получить инкапсуляцию и изоляцию в C с надёжными гарантиями. Не верьте всему, что читаете в Интернете3.
Кроме моего блога, конечно. Очевидно, что я образец честности и мудрости, за исключением случаев, когда это не так.
Комментарии (29)
unreal_undead2
10.08.2023 06:19У нас нет наследования
Стоило бы тогда рассказать про стандартную схему реализации полиморфизма (структура с указателями на функции).
Gryphon88
10.08.2023 06:19В качестве альтернативы предлагают generic macro, которые даже можно автогенерировать с помощью макросной магии)
unreal_undead2
10.08.2023 06:19А можно поконкретнее? Как на них сделать, скажем, объект, который умеет read/write (и при этом иметь несколько реализаций, скрытых от пользователя)?
Gryphon88
10.08.2023 06:19_Generic про параметрический полиморфизм. Обычно делают так:
- Пишут header-only реализацию контейнера, того же листа, например
- Пишут модуль, в котором конкретизируются структуры данных и методы. В этом случае read является _Generic макросом, который подставляется в зависимости от переданного типа, а сокрытие сделано как в описано в статье
Все вместе похоже на программирование на шаблонах в плюсах.
Вот примеры, лучше смотреть в том порядке, какой я предложил, есть разные подходы к реализации: [1], [2], [3]
На самом деле довольно грязно и сложновато в отладке, хоть и не как подход с void*. Поэтому есть несколько подробных статей, как сохранить типобезопасность ([4], [5]) и не острелить себе ноги [6]. Если мы широко используем X-macro, который включается без include guard и который легко забыть undef-нуть, становится еще интереснее)unreal_undead2
10.08.2023 06:19Ну так это не альтернатива, а другая функциональность. Так то все, наверное, когда то писали свои списки для произвольного типа на чистом C )
Batalmv
10.08.2023 06:19Там можно еще делать структры с указательми на функции, и получаем методы. Которые можно "переопределить".
С одной стороны руками это достаточно напряжно, но в конце выглядит прикольно (и очень не читаемо).
unreal_undead2
10.08.2023 06:19-2Что напряжного в том, чтобы объявить несколько указателей в структуре, заполнить в функции инициализации и дальше вызывать obj->method(obj,...) ? Да, нет неявного this и в реализациях надо руками кастить объект к нужному типу - но всё равно дополнительной писанины не так много.
Batalmv
10.08.2023 06:19Сделать несложно. А вот понять что в конкретный момент там за указателем скрывается ...
unreal_undead2
10.08.2023 06:19Так весь смысл инкапсуляции - за указателем скрывается сущность с такими то методами, больше о ней ничего неизвестно. Конкретных реализаций несколько, могут добавляться уже после того, как написан использующий их код - так что закладываться на внутренности в принципе нельзя.
Batalmv
10.08.2023 06:19Просто приятно, когда умная IDE шка подсказывает, или по имени класса все яснт
А вот случае С иди и думай, чего ты там присвоил 100500 строк кода до того
AndreyAf
10.08.2023 06:19+2typedef struct StringBuilder StringBuilder;
самая идиотская вещь, которую смогли придумать Сишники. Эту структуру даже в умный указатель не возможно по нормальному потом завернуть... И всё это ради каких то приватных полей... для компилятора!
unreal_undead2
10.08.2023 06:19Какие умные указатели в C?
MiraclePtr
10.08.2023 06:19+2У GCC и Clang есть расширения, позволяющие сделать умные указатели.
Есть даже занимательная Си'шная библиотека, реализующая аналоги плюсовых unique_ptr и shared_ptr.
Плюс, опять же, довольно часто делается линковка C++ кода с чисто-C либами, и там заворачивание сишной структуры в плюсовый умный указатель встречается довольно часто.
unreal_undead2
10.08.2023 06:19довольно часто делается линковка C++ кода с чисто-C либами
При этом надо сразу понимать, что несмотря на общую базу это разные языки с разыми подходами к реализации одних и тех же концепций, так что часто надо писать явный враппер.
victor_1212
10.08.2023 06:19правильно, все можно, хотя слово "можно" имеет много разных оттенков, мне к примеру без разницы на чем писать embedded sw, что действительно заботит - как и сколько долго придется систему тестировать и отлаживать, имея в виду довольно сложные сценарии для real time, по опыту для embedded С++ больших преимуществ не дает, конечно код можно сделать более читабельный и красивый, но отлаживать будет труднее, хотя многое конечно зависит от личных предпочтений
alexac
10.08.2023 06:19А собственно, что там невозможного, написать кастомный делитер?
#include <memory> struct StringBuilderDeleter { operator()(StringBuilder* ptr) const { DestroyStringBuilder(ptr); } }; using StringBuilderPtr = std::unique_ptr<StringBuilder, StringBuilderDeleter>;
uEvg
10.08.2023 06:19+2Язык С не создавался под ООП парадигму, и подобные конструкции лично на мой взгляд усложняют читаемость кода. Мораль - пишем на С++ если уж очень нужно ООП.
MiraclePtr
А еще он не сможет создать этот StringBuilder на стеке. Придется всегда аллоцировать/деаллоцировать под него память.
И не сможет сделать композицию, поместив его членом какой-то другой структуры (без использования указателя) - и в этом случае при использовании (конструировании, копировании) такой композиции тоже придется постоянно дергать дополнительные аллокации.
На фоне популярного наезда сишников на плюсистов "да у вас на там каждый чих дополнительные явные и неявные аллокации, а мы тут на сях пишем эффективных код без лишнего оверхеда" это очень забавно, и сама фича похожа не на "изящную возможность языка", а на жирный и неудобный костыль.
unreal_undead2
Точно так же как и плюсовый объект, для которого есть только ISomething и фабрика. Автор же не утверждает, что C круче - просто то, что в нём легко реализуется обычная схема инкапсуляции.
HlebyShek
А что мешает создать конструктор, который возвращает StringBuilder*, а принимает указатель на память. Так извне можно аллоцировать буфер хоть на стеке, хоть на хипе. К тому же это лучше с точки зрения архитектуры - разделить ответственность получения ресурса(выделения памяти) и конструирования объекта.
MiraclePtr
Вы это сейчас серьезно?
При реализации "в лоб" (мы просто выделяем буфер, вызываем с ним конструктор, и дальше везде используем) такого пострадает читаемость и типобезопасность - в случае с переменными на стеке и полями по значению в структурах, это будет уже не StringBuilder, а какой-то непонятный буфер типа char[N]. При подсветке переменной/поля IDE покажет не тип StringBuilder, а тип этого буфера, при использовании этого буфера с функциями StringBuilder_ нужно будет либо постоянно брать и кастовать указатель из типа буфера в тип StringBuilder на каждый чих, замусоривая этими кастами код, либо придется сделать так, чтобы они сразу принимали в качестве первого аргумента не тип самого StringBuilder, а тип этого буфера или вообще void* - в итоге никакой типобезопасности (ничего не мешает при использовании по ошибке подсунуть туда буфер, не являющийся StringBuilder, и привет undefined behavior).
Частичным решением проблемы будет объявление чего-нибудь типа `
typedef char StringBuilder[16]
` и использование везде именно его, но тогда на ум приходит вторая проблема.Чтобы выделить место на стеке или в структуре, нужно заранее знать размер требуемого для этого буфера. Вы не можете сделать sizeof() на этапе компиляции, потому что структура билдера описана в и файле имплементации и вам не видна. Вы не можете на этапе компиляции вызвать функцию из иплементации, которая вернет вам sizeof() этой структуры, потому что в сях нету constexpr. Остается только разработчику вручную посчитать размер этого StringBuilder'а, вытащить его в хедер в виде тайпдефа/дефайна/константы как в примере выше, и молиться, чтобы не забыть его при каких-либо изменениях в StringBuffer'е. Криво и костыльно.
unreal_undead2
Можно static_assert'ом проверить - надеюсь, про fastpImpl от Полухина все в курсе ) У него, правда, плюсы, но static_assert и в C11 есть, что то похожее можно сделать.
MiraclePtr
Ну да, так уже можно будет хоть как-то вести дело.
Правда, в любом случае, как по мне, слишком много костылей требуется для решения такой простейшей (в других языках) задачи.
unreal_undead2
Покажите, как проще делается скрытие реализации с возможностью аллокации на стеке. private в C++ эту задачу не решает.
MiraclePtr
В C++ private запрещает всем вызывающим сторонам вмешиваться во внутренние поля объекта - именно эта цель и была завялена в статье, и private ее целиком и полнолстью выполняет.
unreal_undead2
Практически этого недостаточно, нужно убрать зависимость при компиляции - так что в реальных больших плюсовых проектах используется либо чисто абстрактный интерфейс, либо pimpl c разными вариациями.
Apoheliy
Что-то не вижу проблем (поправьте, если что):
MiraclePtr
Проблемы следующие:
alloca() не является частью стандарта Си, может не поддерживаться какими-то компиляторами = не гарантирована переносимость
Есть известная проблема с тем, что можно очень больно отстрелить ногу, если компилятор вдруг заинлайнит функцию, содержащую alloca()
По-прежнему не решена проблема StringBuilder как члена
объектаструктуры - ее тоже будет невозможно положить на стек и придется делать alloca() для всей содержащей структуры целиком.HlebyShek
Способ я не предлагал. Но думаю вы правы в чем-то.
Можно разместить на стеке? - Да
Будет ли это удобно, как при обычном использовании стека? - Нет