Интернет - первое, что построило человечество и чего человечество не понимает, крупнейший эксперимент в анархии за всю нашу историю .-- Эрик Шмидт

Контекст

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

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

Миф: поля структур не могут быть скрытыми

Давайте взглянем на определение класса, имеющего члены private; в конце-концов, ведь именно так возникает изоляция, правда? Если бы все поля были публичными, то это был бы просто C, но с наследованием1.

  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.

  1. См. swig

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

Ладно, вы всё поняли, пора заканчивать

Вот так можно получить инкапсуляцию и изоляцию в C с надёжными гарантиями. Не верьте всему, что читаете в Интернете3.

  1. Кроме моего блога, конечно. Очевидно, что я образец честности и мудрости, за исключением случаев, когда это не так.

Комментарии (29)


  1. MiraclePtr
    10.08.2023 06:19
    +13

    Да он даже не сможет выполнить malloc() для своего собственного экземпляра StringBuilder, потому что скрыт даже размер StringBuilder.

    А еще он не сможет создать этот StringBuilder на стеке. Придется всегда аллоцировать/деаллоцировать под него память.
    И не сможет сделать композицию, поместив его членом какой-то другой структуры (без использования указателя) - и в этом случае при использовании (конструировании, копировании) такой композиции тоже придется постоянно дергать дополнительные аллокации.

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


    1. unreal_undead2
      10.08.2023 06:19
      +4

      А еще он не сможет создать этот StringBuilder на стеке

      Точно так же как и плюсовый объект, для которого есть только ISomething и фабрика. Автор же не утверждает, что C круче - просто то, что в нём легко реализуется обычная схема инкапсуляции.


    1. HlebyShek
      10.08.2023 06:19
      -1

      А что мешает создать конструктор, который возвращает StringBuilder*, а принимает указатель на память. Так извне можно аллоцировать буфер хоть на стеке, хоть на хипе. К тому же это лучше с точки зрения архитектуры - разделить ответственность получения ресурса(выделения памяти) и конструирования объекта.


      1. MiraclePtr
        10.08.2023 06:19
        +1

        Вы это сейчас серьезно?

        При реализации "в лоб" (мы просто выделяем буфер, вызываем с ним конструктор, и дальше везде используем) такого пострадает читаемость и типобезопасность - в случае с переменными на стеке и полями по значению в структурах, это будет уже не StringBuilder, а какой-то непонятный буфер типа char[N]. При подсветке переменной/поля IDE покажет не тип StringBuilder, а тип этого буфера, при использовании этого буфера с функциями StringBuilder_ нужно будет либо постоянно брать и кастовать указатель из типа буфера в тип StringBuilder на каждый чих, замусоривая этими кастами код, либо придется сделать так, чтобы они сразу принимали в качестве первого аргумента не тип самого StringBuilder, а тип этого буфера или вообще void* - в итоге никакой типобезопасности (ничего не мешает при использовании по ошибке подсунуть туда буфер, не являющийся StringBuilder, и привет undefined behavior).
        Частичным решением проблемы будет объявление чего-нибудь типа `typedef char StringBuilder[16]` и использование везде именно его, но тогда на ум приходит вторая проблема.

        Чтобы выделить место на стеке или в структуре, нужно заранее знать размер требуемого для этого буфера. Вы не можете сделать sizeof() на этапе компиляции, потому что структура билдера описана в и файле имплементации и вам не видна. Вы не можете на этапе компиляции вызвать функцию из иплементации, которая вернет вам sizeof() этой структуры, потому что в сях нету constexpr. Остается только разработчику вручную посчитать размер этого StringBuilder'а, вытащить его в хедер в виде тайпдефа/дефайна/константы как в примере выше, и молиться, чтобы не забыть его при каких-либо изменениях в StringBuffer'е. Криво и костыльно.


        1. unreal_undead2
          10.08.2023 06:19
          -2

          и молиться, чтобы не забыть его при каких-либо изменениях в StringBuffer'е

          Можно static_assert'ом проверить - надеюсь, про fastpImpl от Полухина все в курсе ) У него, правда, плюсы, но static_assert и в C11 есть, что то похожее можно сделать.


          1. MiraclePtr
            10.08.2023 06:19

            Ну да, так уже можно будет хоть как-то вести дело.

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


            1. unreal_undead2
              10.08.2023 06:19
              -2

              Покажите, как проще делается скрытие реализации с возможностью аллокации на стеке. private в C++ эту задачу не решает.


              1. MiraclePtr
                10.08.2023 06:19

                private в C++ эту задачу не решает

                В C++ private запрещает всем вызывающим сторонам вмешиваться во внутренние поля объекта - именно эта цель и была завялена в статье, и private ее целиком и полнолстью выполняет.


                1. unreal_undead2
                  10.08.2023 06:19

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


        1. Apoheliy
          10.08.2023 06:19

          Что-то не вижу проблем (поправьте, если что):

          size_t GetStringBuilderSizeof(); // Реализован где-то
          
          
          В коде пользовательской функции:
          
          StringBuilder* sb = (StringBuilder*)alloca(GetStringBuilderSizeof());
          // Далее используем объект на стеке: явный вызов конструктора и т.д.
          


          1. MiraclePtr
            10.08.2023 06:19

            Проблемы следующие:

            1. alloca() не является частью стандарта Си, может не поддерживаться какими-то компиляторами = не гарантирована переносимость

            2. Есть известная проблема с тем, что можно очень больно отстрелить ногу, если компилятор вдруг заинлайнит функцию, содержащую alloca()

            3. По-прежнему не решена проблема StringBuilder как члена объекта структуры - ее тоже будет невозможно положить на стек и придется делать alloca() для всей содержащей структуры целиком.


        1. HlebyShek
          10.08.2023 06:19

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


  1. unreal_undead2
    10.08.2023 06:19

    У нас нет наследования

    Стоило бы тогда рассказать про стандартную схему реализации полиморфизма (структура с указателями на функции).


    1. Gryphon88
      10.08.2023 06:19

      В качестве альтернативы предлагают generic macro, которые даже можно автогенерировать с помощью макросной магии)


      1. unreal_undead2
        10.08.2023 06:19

        А можно поконкретнее? Как на них сделать, скажем, объект, который умеет read/write (и при этом иметь несколько реализаций, скрытых от пользователя)?


        1. Gryphon88
          10.08.2023 06:19

          _Generic про параметрический полиморфизм. Обычно делают так:


          1. Пишут header-only реализацию контейнера, того же листа, например
          2. Пишут модуль, в котором конкретизируются структуры данных и методы. В этом случае read является _Generic макросом, который подставляется в зависимости от переданного типа, а сокрытие сделано как в описано в статье

          Все вместе похоже на программирование на шаблонах в плюсах.
          Вот примеры, лучше смотреть в том порядке, какой я предложил, есть разные подходы к реализации: [1], [2], [3]
          На самом деле довольно грязно и сложновато в отладке, хоть и не как подход с void*. Поэтому есть несколько подробных статей, как сохранить типобезопасность ([4], [5]) и не острелить себе ноги [6]. Если мы широко используем X-macro, который включается без include guard и который легко забыть undef-нуть, становится еще интереснее)


          1. unreal_undead2
            10.08.2023 06:19

            Ну так это не альтернатива, а другая функциональность. Так то все, наверное, когда то писали свои списки для произвольного типа на чистом C )


  1. Batalmv
    10.08.2023 06:19

    Там можно еще делать структры с указательми на функции, и получаем методы. Которые можно "переопределить".

    С одной стороны руками это достаточно напряжно, но в конце выглядит прикольно (и очень не читаемо).


    1. unreal_undead2
      10.08.2023 06:19
      -2

      Что напряжного в том, чтобы объявить несколько указателей в структуре, заполнить в функции инициализации и дальше вызывать obj->method(obj,...) ? Да, нет неявного this и в реализациях надо руками кастить объект к нужному типу - но всё равно дополнительной писанины не так много.


      1. Batalmv
        10.08.2023 06:19

        Сделать несложно. А вот понять что в конкретный момент там за указателем скрывается ...


        1. unreal_undead2
          10.08.2023 06:19

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


          1. Batalmv
            10.08.2023 06:19

            Просто приятно, когда умная IDE шка подсказывает, или по имени класса все яснт

            А вот случае С иди и думай, чего ты там присвоил 100500 строк кода до того


  1. AndreyAf
    10.08.2023 06:19
    +2

    typedef struct StringBuilder StringBuilder;

    самая идиотская вещь, которую смогли придумать Сишники. Эту структуру даже в умный указатель не возможно по нормальному потом завернуть... И всё это ради каких то приватных полей... для компилятора!


    1. unreal_undead2
      10.08.2023 06:19

      Какие умные указатели в C?


      1. MiraclePtr
        10.08.2023 06:19
        +2

        У GCC и Clang есть расширения, позволяющие сделать умные указатели.

        Есть даже занимательная Си'шная библиотека, реализующая аналоги плюсовых unique_ptr и shared_ptr.

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


        1. unreal_undead2
          10.08.2023 06:19

          довольно часто делается линковка C++ кода с чисто-C либами

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


        1. victor_1212
          10.08.2023 06:19

          правильно, все можно, хотя слово "можно" имеет много разных оттенков, мне к примеру без разницы на чем писать embedded sw, что действительно заботит - как и сколько долго придется систему тестировать и отлаживать, имея в виду довольно сложные сценарии для real time, по опыту для embedded С++ больших преимуществ не дает, конечно код можно сделать более читабельный и красивый, но отлаживать будет труднее, хотя многое конечно зависит от личных предпочтений


    1. alexac
      10.08.2023 06:19

      А собственно, что там невозможного, написать кастомный делитер?

      #include <memory>
      
      struct StringBuilderDeleter {
        operator()(StringBuilder* ptr) const {
          DestroyStringBuilder(ptr);
        }
      };
      
      using StringBuilderPtr = std::unique_ptr<StringBuilder, StringBuilderDeleter>;
      


  1. uEvg
    10.08.2023 06:19
    +2

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