saneex.c: try/catch/finally на базе setjmp/longjmp (C99) быстрее стандартных исключений C++¹

    Пока писал эту сугубо техническую статью, Хабр успел превратиться в местное отделение ВОЗ и теперь мне даже стыдно ее публиковать… но в душе теплится надежда, что айтишники еще не разбежались и она найдет своего читателя. Или нет?




    Меня всегда восхищала стандартная библиотека Си, да и сам Си — при всей своей минималистичности от них так и веет духом тех самых первых красноглазиков хакеров. В черновике первого официального стандарта (ANSI C, он же C89, он же ANS X3.159-1989, он же, позднее, C90 и IEC 9899:1990) определяется 145 функций и макросов, из них около 25 — это вариации (ввиду отсутствия в языке перегрузок), а 26 чисто математических. K&R во второй редакции² приводят 114 функций (плюс математические), считая остальные за экзотику. В черновике³ C11 функций уже 348, но больше сотни — математика, а еще штук 90 это «перегрузки». А теперь посмотрим на Boost, где одних только библиотек — 160. Чур меня…


    И среди этой сотни-полутора функций всегда были: обработка сигналов, вариативные функции (которые до интерпретируемого PHP дошли 25 лет спустя, а в Delphi, бурно развивавшемся одно время, их нет до сих пор) и порядка 50 строковых функций вроде printf() (м-м-м… JavaScript), strftime() (…) и scanf() (дешевая альтернатива регуляркам).



    А еще всегда были setjmp()/longjmp(), которые позволяют реализовать привычный по другим языкам механизм исключений, не выходя за рамки переносимого Си. Вот о них и поговорим — Quake World, стеки, регистры, ассемблеры и прочая матчасть, а вишенкой будет занятная статистика (спойлер: Visual Studio непостоянна, как мартовский заяц, а throw saneex.c в два раза быстрее всех).



    Скрытый текст

    ¹ По результатам замеров в статье.


    ² Кстати, книга великолепная. 270 страниц, из которых 80 — это краткий пересказ стандарта. Или в то время еще не умели растекаться мыслью по древу и конвертировать это в гонорар, или авторы были выше этого. K&R — старая школа, чо.


    ³ Из особо достоверных источников известно, что финальные версии стандартов ANSI и ISO продаются за деньги, а черновики бесплатны. Но это не точно.


    ⁴ Да, я тоже не люблю «сокращалки» вроде TinyURL, но парсер Хабра считает URL частью текста и ругается на длинный текст до ката, яко Твіттер поганий. Дальше этого не будет, честно-честно. Параноикам могу посоветовать urlex.org.


    Оглавление:



    Итак, герои нашей программы — setjmp()/longjmp(), определенные в setjmp.h, которые любят вместе сокращать как «SJLJ» (хотя мне это слово не нравится, напоминает одну печально известную аббревиатуру). Они появились в C89 и, в общем-то, уходить не собираются, но про них не все знают (знать не значит использовать — знание полезно, а использование — как повезет).


    Справедливости ради надо сказать, что на Хабре уже были статьи, посвященные этой теме, в особенности отличная статья от zzeng. В англоязычной Сети, конечно, тоже имеется, плюс можно найти реализации вроде такой или даже вот такой¹, но, на мой взгляд, у них есть фатальный недостаток результат или не до конца привычен (к примеру, нельзя выбрасывать исключения повторно), или используются механизмы не по стандарту.


    ¹ CException хочется отметить особо — всего 60 строчек, пишут, что работает быстро, тоже ANSI C, но у него нет finally и текстовых сообщений, что для меня принципиально важно.


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


    Для меня лично исключения это инструмент, который позволяет:


    • не думать в каждом конкретном месте, что что-то может пойти не так, если это самое место все равно с этим ничего не может сделать (ресурсы не заблокированы, память не выделена — можно прерываться немедленно, без if (error) return -1;)
    • когда что-то и впрямь пошло не так — сохранить как можно больше информации, от кода ошибки и имени файла до значения важных переменных и других исключений, которые породили эту ситуацию

    Но обо всем по порядку. Как это у нас принято, начнем с матчасти.



    Как работают setjmp()/longjmp()



    Регистры, стек и все-все-все


    В двух словах, longjmp() — это нелокальный goto, а setjmp() — пророк его способ задания метки этому goto в run-time. Короче, «goto на стероидах». И, как и любые стероиды, то бишь, goto, они могут нанести непоправимый вред вашему коду — превратить его в такую лапшу, которая для goto просто вне досягаемости. Посему лучше всего их использовать не напрямую, а внутри какой-нибудь обертки, задающей четкую иерархию переходов (как то исключения — вверх по стеку в пределах явно обозначенных блоков «try»).


    Помните, я говорил в начале, что от Си и, конкретно, от setjmp.h прямо веет черт^W юниксовщиной? Так вот, вы вызываете setjmp() один раз, а она возвращается сколько угодно раз (но, как минимум, один). Да, в обычном мире смузихлебы вызывают функцию и она возвращается один раз, а в Советской России функция вызывает вас сама, сколько раз ей хочется и когда ей этого хочется. Такие дела.


    Эта концепция, кстати, воплотилась не только в setjmp() — fork() в POSIX делает нечто очень похожее. Я помню, когда я впервые знакомился с *nix’овыми API после десятка лет работы исключительно с WinAPI, мне просто сносило крышу — в моих ментальных шаблонах не укладывалось, что функции могут вот так себя вести. Как метко говорят — «а что, так можно было?»… Но мы отвлеклись.



    Думаю, все читающие в курсе, что основной элемент рантайма — это стек, на котором лежат параметры и (некоторые) локальные переменные данной функции. Вызываешь новую функцию — стек растет (причем у Intel’а — вниз), выходишь — тает (у Intel’а — да-да, вверх). Вот примерчик:


    void sub(int s) {
      char buf[256];
      sub(2);
    }
    
    int main(int m) {
      sub(1);
    }

    Есть такой занятный компилятор — tcc (Tiny C Compiler) от известного программиста-парохода Ф. Беллара. tcc практически не делает оптимизаций и код после него очень приятно смотреть в дизассемблере. Он генерирует такое тело для sub()нотации Intel, опуская пролог и эпилог):


    sub     esp, 100h       ; выделяем место под локальную переменную
    mov     eax, 2          ; передаем параметр
    push    eax
    call    sub_401000      ; вызываем sub()
    add     esp, 4          ; очищаем стек после возврата (= cdecl)

    Вот схемка происходящего со стеком:



    Вот эти оранжевые цифры по центру — это указатель на вершину стека (который у Intel… ну, вы поняли). Указатель хранится в регистре ESP (RSP на x86_64). setjmp() сохраняет текущее значение ESP/RSP, плюс другие служебные регистры, в область памяти jmp_buf, которую вы ему передаете. Если происходит вызов longjmp() далее по курсу (из этой же функции или из подфункции) — указатель восстанавливается и получается, что следом автоматически восстанавливается и окружение функции, где был вызван setjmp(), а все вызванные ранее подфункции моментально завершаются (возвращаются). Эдакий откат во времени, «undo» для рантайма (конечно, с большой натяжкой).


    В следующем примере setjmp() поместит в jmp значение указателя FEF8h (FDF0h и т.д. — красные стрелки на схеме выше) и функция продолжит выполнение, как обычно:


    void sub(int s) {
      char buf[256];
      jmp_buf jmp;
      setjmp(jmp);
      sub(2);
    }

    Но, конечно, есть нюанс™:


    • нельзя прыгать между потоками (setjmp() в одном, longjmp() в другом), потому как, очевидно, у каждого потока свой стек
    • если функция, которая вызвала setjmp(), уже вернулась, то «реанимировать» ее не выйдет — программа впадет в undefined behavior (и это не лечится)


    • компилятор использует регистры для хранения переменных — они, видите ли, быстрее работают! — а регистры, внезапно, хранятся отдельно от стека и, хотя setjmp() могла сохранить их состояние на момент вызова, она и longjmp() не знают, что с ними происходило после вызова setjmp()


    Затирание переменных или, по-русски, clobbering


    Последний момент особенно интересен. Пример:


    #include <stdlib.h>
    #include <stdio.h>
    #include <setjmp.h>
    
    int main(void) {
      int i;
      jmp_buf jmp;
      i = rand();
      if (setjmp(jmp) == 0) {
        i = rand();
        printf("%d\n", i);
        longjmp(jmp, 1);
      } else {
        printf("%d\n", i);
      }
    }

    Вопрос залу: будут ли числа в консоли совпадать?


    Правильный ответ: зависит от воли звезд. Так-то!


    Посмотрим, что происходит на примере gcc. Если скомпилировать с -O0, то числа будут совпадать, а в дизассемблере мы увидим вот это:


    ; int main(void) {
      push    ebp             ; пролог (создается stack frame)
      mov     ebp, esp        ; EBP указывает на стек ниже ESP (если по схеме)
      sub     esp, E0h
      ...
      call    _rand           ; результат возвращается в EAX
      mov     [ebp-D4h], eax  ; это i = rand(); где i на стеке (EBP-D4h)
      ...
    ; if (... == 0) {         ; вызов setjmp() и возврат из нее до прыжка
      call    _rand
      mov     [ebp-D4h], eax  ; снова i = rand(); на стеке
    ; printf("%d\n", i);
      mov     eax, [ebp-D4h]  ; передаем i со стека как параметр
      mov     esi, eax
      lea     edi, format     ; передаем строку "%d\n"
      mov     eax, 0
      call    _printf
      ...
    ; } else {                ; вторичный возврат из setjmp() после прыжка
      mov     eax, [ebp-D4h]  ; снова передаем i, как в ветке выше
      mov     esi, eax
      lea     edi, format     ; "%d\n"
      mov     eax, 0
      call    _printf

    Как видно, компилятор не заморачивался и поместил переменную i в стек (по адресу EBP - D4h). Если смотреть на всю ту же схемку, то:


    • вместо буфера на 256 char мы имеем int и jmp_buf, размер которых на моей системе 4 и 200 байт соответственно, плюс 20 байт для чего-то потребовалось компилятору, так что на стеке под локальные переменные выделилось 224 байта (E0h) вместо 100h, как в том примере
    • ESP на момент вызова setjmp() равен FFF8h - E0h = FF18h (вместо FEF8h), это значение и сохраняется в jmp
      • конечно, это значение условно, в реальности оно будет иным
    • и первое присваивание i, и второе меняют значение i в стеке (по адресу FF18h)
    • longjmp() сбрасывает указатель стека обратно в FF18h, но, так как переменная i не выходит за эти границы, она по-прежнему доступна, равно как и другая переменная (jmp), и параметры main() (буде они есть)
      • в этом примере ESP и так не менялся, но longjmp() легко мог бы быть внутри другой функции, вызванной из main()

    А вот если включить хотя бы -O1, то картина изменится:


    ; пролога и stack frame больше нет, используется значение ESP напрямую
      sub     esp, E8h
      ...
      call    _rand
      mov     [esp+E8h-DCh], eax  ; i = rand(); в стеке, как и с -O0
      ...
    ; -O1 почему-то решило, что выполнение else более вероятно, чем
    ; if (setjmp() == 0) (хотя по-моему наоборот), и переставило
    ; их местами; здесь я вернул прежний порядок для понятности
    ; if (... == 0) {
      call    _rand
      mov     esi, eax            ; ВНИМАНИЕ! запись i в регистр
    ; printf("%d\n", i);
      lea     edi, format     ; "%d\n"
      mov     eax, 0
      call    _printf
      ...
    ; } else {
      mov     esi, [esp+E8h-DCh]  ; ВНИМАНИЕ! чтение i со стека
      lea     edi, format     ; "%d\n"
      mov     eax, 0
      call    _printf

    Вдобавок, с -O1 gcc при компиляции ругается страшными словами:


    test.c:6:11: warning: variable ‘i’ might be clobbered by ‘longjmp’ or ‘vfork’ [-Wclobbered]

    Что мы здесь видим? Вначале i помещается в регистр, но в первой ветке (внутри if) gcc, видимо сочтя i не используемой после первого printf(), помещает новое значение сразу в ESI, а не в стек (через ESI оно передается дальше в printf(), см. ABI, стр. 22 — RDI (format), RSI (i), …). Из-за этого:


    • в стеке по адресу ESP + E8h - DCh остается старое значение rand()
    • в ESI оказывается новое значение
    • printf() (первый вызов) принимает (новое) значение из регистра
    • longjmp() сбрасывает указатель стека, но не восстанавливает изменившиеся регистры, которые используются функциями для локальных переменных при включенных оптимизациях
    • второй вызов printf()else) читает значение, как положено, из стека, то бишь старое
      • но даже если бы оно читалось из ESI, то после прыжка в этом регистре был бы мусор (вероятно, из printf() или самого longjmp())


    Или, если переписать это обратно на Си:


    stack[i] = rand();          // i = rand(); изменение стека (1)
    if (setjmp(jmp) == 0) {
      ESI = rand();             // i = rand(); изменение регистра (2)
      printf("%d\n", ESI);      // печать значения (2)
      longjmp(jmp, 1);          // прыжок
    } else {
      printf("%d\n", stack[i]); // печать значения (1)
      // или могло бы быть так:
      printf("%d\n", ESI);      // использование регистра, где уже кто-то
                                // "побывал" (первый printf() или longjmp())
    }

    Скрытый текст

    Честно говоря, мне не понятно, почему gcc результат первого rand() не помещает сразу в ESI или в другой регистр (даже при -O3). На SO пишут, что в режиме x86_64 (под который я компилировал пример) сохраняются все регистры, кроме EAX. Зачем промежуточное сохранение в стек? Я предположил, что gcc отследил printf() в else после longjmp(), но если убрать второй rand() и этот printf() — результат не меняется, i так же вначале пишется в стек.


    Если кто может пролить свет на сию тайну — прошу в комментарии.



    Квалификатор volatile


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


    volatile int i;

    Единственное изменение при -O1 будет в теле if:


    ; было:
      call    _rand
      mov     esi, eax
    ; стало:
      call    _rand
      mov     [rsp+E8h-DCh], eax
      mov     esi, [rsp+E8h-DCh]
    ; или можно переписать так:
      call    _rand
      mov     esi, eax
      mov     [rsp+E8h-DCh], eax

    Как видим, компилятор продублировал присвоение в стек (сравните):


    if (setjmp(jmp) == 0) {
      ESI = stack[i] = rand();


    Случаи использования IRL


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



    Последний пример, на мой взгляд, наиболее хрестоматийный — это обработка ошибок и других состояний, когда нужно выйти «вот прямо сейчас», с любого уровня, при этом вставлять везде проверки на выход утомительно, а где-то и не возможно (библиотеки). Кстати, еще один пример был описан в проекте DrMefistO.


    Конкретно в Quake World запускается бесконечный цикл в WinMain(), где каждая новая итерация устанавливает jmp_buf, а несколько функций могут в него прыгать, таким образом реализуя «глубокий continue»:


    // WinQuake/host.c
    jmp_buf         host_abortserver;
    
    void Host_EndGame (char *message, ...)
    {
      ...
    
      if (cls.demonum != -1)
        CL_NextDemo ();
      else
        CL_Disconnect ();
    
      longjmp (host_abortserver, 1);
    }
    
    void Host_Error (char *error, ...)
    {
      ...
    
      if (cls.state == ca_dedicated)
        Sys_Error ("Host_Error: %s\n",string);  // dedicated servers exit
    
      CL_Disconnect ();
      cls.demonum = -1;
    
      inerror = false;
    
      longjmp (host_abortserver, 1);
    }
    
    void _Host_Frame (float time)
    {
      static double           time1 = 0;
      static double           time2 = 0;
      static double           time3 = 0;
      int                     pass1, pass2, pass3;
    
      if (setjmp (host_abortserver) )
        return;                 // something bad happened, or the server disconnected
    
      ...
    }
    
    // QW/client/sys_win.c
    int WINAPI WinMain (...)
    {
      ...
    
      while (1)
      {
        ...
        newtime = Sys_DoubleTime ();
        time = newtime - oldtime;
        Host_Frame (time);
        oldtime = newtime;
      }
    
      /* return success of application */
      return TRUE;
    }


    Производительность


    Один из доводов, который приводят против использования исключений — их отрицательное влияние на производительность. И действительно, в исходниках setjmp() в glibc видно, что сохраняются почти все регистры общего назначения ЦП. Тем не менее:


    • само собой разумеется, что ни исключения в общем, ни SJLJ/saneex.c в частности и не предполагаются к применению во внутренностях числодробилок
    • современные те-кхе…кхе-нологии (извиняюсь, электрон в горло попал) таковы, что сохранение лишнего десятка-другого регистров — это самая малая из проблем, которые они в себе несут
    • если скорость критична, а исключений хочется — есть механизмы zero-cost exceptions (или, точнее, zero-cost try), которые радикально снижают нагрузку при входе в блок try, оставляя всю грязную работу на момент обработки (выброса) — а так как исключения это не goto и должны использоваться, гм, в исключительных ситуациях, то на производительности такой «перекос» сказывается, э-э, исключительно положительно

    «Честные» zero-cost exceptions особенно полезны в том плане, что избавляют от более медленных volatile-переменных, которые иначе размещаются в стеке, а не в регистрах (именно поэтому они и не затираются longjmp()). Тем не менее, их поддержка это уже задача для компилятора и платформы:


    • В Windows есть SEH и VEH, последний подвезли в XP.
    • В gcc было несколько разных вариантов — вначале на основе SJLJ, потом DWARF, коего на сегодняшний день было пять версий (DWARF применяется и в clang). На эту тему см. отменные статьи zzeng: тыц и тыц, и сайт dwarfstd.org.
    • В комментарии к другой статье камрад nuit дал наводку на интересный проект libunwind, но использовать его только ради исключений — это как стрелять из воробьев по пушкам (больно большой).

    И, хотя saneex.c не претендует на пальму zero-cost (ее пальма — это переносимость), так ли уж страшен setjmp(), как его малюют? Может, это суеверие? Чтобы не быть голословными — померяем.



    Тестовая среда


    Я набросал два бенчмарка «на коленке», которые в main() в цикле 100 тысяч раз входят в блок try/catch и делают или не делают throw().


    Исходник бенчмарка на C:


    #include <stdio.h>
    #include <time.h>
    #include "saneex.h"
    
    int main(void) {
      for (int i = 0; i < 100000; i++) {
        try {
          // либо ("выброс" = да):
          throw(msgex("A quick fox jumped over a red dog and a nyancat was spawned"));
          // либо ("выброс" = нет):
          time(NULL);
        } catchall {
          fprintf(stderr, "%s\n", curex().message);
        } endtry
      }
    }

    Исходник на С++ (я адаптировал пример с Википедии, вынеся объявление вектора за цикл и заменив cerr << на fprintf()):


    #include <iostream>
    #include <vector>
    #include <stdexcept>
    #include <time.h>
    
    int main() {
      std::vector<int> vec{ 3, 4, 3, 1 };
    
      for (int i = 0; i < 100000; i++) {
        try {
          // либо ("выброс" = да):
          int i{ vec.at(4) };
          // либо ("выброс" = нет):
          time(NULL);
        }
        catch (std::out_of_range & e) {
          // << вместо fprintf() вызывает замедление цикла на 25-50%
          //std::cerr << "Accessing a non-existent element: " << e.what() << '\n';
          fprintf(stderr, "%s\n", e.what());
        }
        catch (std::exception & e) {
          //std::cerr << "Exception thrown: " << e.what() << '\n';
          fprintf(stderr, "%s\n", e.what());
        }
        catch (...) {
          //std::cerr << "Some fatal error\n";
          fprintf(stderr, "Some fatal error");
        }
      }
    
      return 0;
    }

    Тестировалось все на одной машине в двух ОС (обе 64-битные):


    • Windows 10 2019 LTSC под PowerShell с помощью Measure-Command { test.exe 2>$null }
    • последний Live CD Ubuntu с помощью встроенной time

    Также я попробовал замерить исключения в Windows через расширения __try/__except, взяв другой пример с Википедии:


    #include <windows.h>
    #include <stdio.h>
    #include <vector>
    
    int filterExpression(EXCEPTION_POINTERS* ep) {
      ep->ContextRecord->Eip += 8;
      return EXCEPTION_EXECUTE_HANDLER;
    }
    
    int main() {
      static int zero;
      for (int i = 0; i < 100000; i++) {
        __try {
          zero = 1 / zero;
          __asm {
            nop
            nop
            nop
            nop
            nop
            nop
            nop
          }
          printf("Past the exception.\n");
        }
        __except (filterExpression(GetExceptionInformation())) {
          printf("Handler called.\n");
        }
      }
    }

    Однако вектор включить в цикл не вышло — компилятор сообщил, что:


    error C2712: Cannot use __try in functions that require object unwinding

    Так как накладываемые ограничения на код идут вразрез с принципом привычности, о котором я говорил в начале, я не внес эти результаты в таблицу ниже. Ориентировочно это 1100-1300 мс (Debug или Release, x86) — быстрее, чем стандартные исключения в VS, но все равно медленнее, чем они же в g++.



    Результаты


    №   Компилятор        Конфиг    Платф Механизм  Выброс  Время (мс)¹           saneex медленнее
    
    1.  VS 2019 v16.0.0   Debug     x64   saneex.c  да      9713  / 8728  = 1.1   в 1.8 / 1.8
    2.  VS 2019 v16.0.0   Debug     x64   saneex.c  нет     95    / 46    = 2     в 4.5 / 2.3
    3.  VS 2019 v16.0.0   Debug     x64   C++       да      5449  / 4750² = 1.6
    4.  VS 2019 v16.0.0   Debug     x64   C++       нет     21    / 20    = 1
    5.  VS 2019 v16.0.0   Release   x64   saneex.c  да      8542³ / 182   = 47    в 1.8 / 0.4
    6.  VS 2019 v16.0.0   Release   x64   saneex.c  нет     80³   / 23    = 3.5   в 8   / 1.8
    7.  VS 2019 v16.0.0   Release   x64   C++       да      4669³ / 420   = 11
    8.  VS 2019 v16.0.0   Release   x64   C++       нет     10³   / 13    = 0.8
    9.  gcc 9.2.1         -O0       x64   saneex.c  да      71    / 351   = 0.2   в 0.2 / 0.6
    10. gcc 9.2.1         -O0       x64   saneex.c  нет     6     / 39    = 0.2   в 1.5 / 1.1
    11. g++ 9.2.1         -O0       x64   C++       да      378   / 630   = 0.6
    12. g++ 9.2.1         -O0       x64   C++       нет     4     / 37    = 0.1
    13. gcc 9.2.1         -O3       x64   saneex.c  да      66    / 360   = 0.2   в 0.2 / 0.6
    14. gcc 9.2.1         -O3       x64   saneex.c  нет     5     / 23    = 0.2   в 1   / 0.6
    15. g++ 9.2.1         -O3       x64   C++       да      356   / 605   = 0.6
    16. g++ 9.2.1         -O3       x64   C++       нет     5     / 38    = 0.1

    Скрытый текст

    ¹ В столбце Время добавлены замеры одного из читателей на Windows 7 SP1 x64 с VS 2017 v15.9.17 и gcc под cygwin.


    ² Крайне странный факт: если fprintf() заменить на cerr <<, то время выполнения сократится в 3 раза: 1386/1527 мс.


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


    Результаты получились… интересные:


    • Показатели сильно плавают на разных машинах и/или окружениях и особенно «чудит» VS. Чем это вызвано — непонятно.
    • Использование cerr << вместо fprintf() в паре с выбросом исключения в VS в отладочной сборке ускоряет цикл в 3-4 раза (строка 3). ЧЯДНТ?
    • Во всех случаях расходы на блок try в отсутствие throw — мизерные (4-28 мс на 100 тысяч итераций).
    • Не считая «разогнанного» Debug в VS, выброс исключений в saneex.c быстрее, чем во встроенных языковых конструкциях (в 2.3 раза быстрее VS, в 5 раз быстрее gcc/g++), а try без throw — помедленнее, но речь идет о единицах миллисекунд. Вот это поворот!

    Что тут можно сказать… Есть о чем похоливарить. Добро пожаловать в комментарии!


    Для меня самый важный use-case — это много блоков try с крайне редкими throw («лови много, бросай мало»), а он зависит практически только от скорости setjmp(), причем производительность последнего, судя по таблице, далеко не так плоха, как часто думают. Косвенно это подтверждается и вот этой статьей, где автор после замеров делает вывод, что один вызов setjmp() равен двум вызовам пустых функций в OpenBSD и полутора (1.45) — в Solaris. Причем эта статья от 2005 года. Единственное «но» — сохранять нужно без сигнальной маски, но она обычно и не интересна.


    Ну, а напоследок…



    Виновник торжества — saneex.c


    Библиотека, чей пример был на КДПВ:


    • может компилироваться даже в Visual Studio
    • поддерживает любую вложенность блоков, throw() из любого места, finally и несколько catch на блок (по коду исключения)
    • не выделяет память и не использует указатели (все в static)
    • опционально-многопоточная (__thread/_Thread_local)
    • в public domain (CC0)


    Интересующиеся могут найти ее исходники на GitHub. Ниже я кратко на одном примере покажу, как ей пользоваться и какие есть подводные камни. Код примера из saneex-demo.c в репозитории:


    01.    #include <stdio.h>
    02.    #include "saneex.h"
    03.
    04.    int main(void) {
    05.      sxTag = "SaneC's Exceptions Demo";
    06.
    07.      try {
    08.        printf("Enter a message to fail with: [] [1] [2] [!] ");
    09.
    10.        char msg[50];
    11.        thrif(!fgets(msg, sizeof(msg), stdin), "fgets() error");
    12.
    13.        int i = strlen(msg) - 1;
    14.        while (i >= 0 && msg[i] <= ' ') { msg[i--] = 0; }
    15.
    16.        if (msg[0]) {
    17.          errno = atoi(msg);
    18.          struct SxTraceEntry e = newex();
    19.          e = sxprintf(e, "Your message: %s", msg);
    20.          e.uncatchable = msg[0] == '!';
    21.          throw(e);
    22.        }
    23.
    24.        puts("End of try body");
    25.
    26.      } catch (1) {
    27.        puts("Caught in catch (1)");
    28.        sxPrintTrace();
    29.
    30.      } catch (2) {
    31.        puts("Caught in catch (2)");
    32.        errno = 123;
    33.        rethrow(msgex("calling rethrow() with code 123"));
    34.
    35.      } catchall {
    36.        printf("Caught in catchall, message is: %s\n", curex().message);
    37.
    38.      } finally {
    39.        puts("Now in finally");
    40.
    41.      } endtry
    42.
    43.      puts("End of main()");
    44.    }

    Программа выше читает сообщение, бросает исключение и обрабатывает его в зависимости от пользовательского ввода:


    • если ничего не ввести — исключение выброшено не будет, и мы увидим:

    End of try body
    Now in finally
    End of main()

    • если ввести текст, начинающийся с единицы, то будет создано исключение с этим кодом (1), оно будет поймано в первом блоке catch (1) (26.), а на экране появится:

    Caught in catch (1)
    Your message: 1 hello, habr!
        ...at saneex-demo.c:18, code 1
    Now in finally
    End of main()

    • если ввести двойку, то исключение будет поймано (30.), выброшено новое (со своим кодом, текстом и прочим) с сохранением предыдущей информации в цепочке (33.), дойдет до внешнего обработчика и программа завершится:

    Caught in catch (2)
    Now in finally
    
    Uncaught exception (code 123) - terminating. Tag: SaneC's Exceptions Demo
    Your message: 2 TM! kak tam blok4ain?
        ...at saneex-demo.c:18, code 2
    calling rethrow() with code 123
        ...at saneex-demo.c:33, code 123
    rethrown by ENDTRY
        ...at saneex-demo.c:41, code 123

    • если ввести !, то исключение получится «неуловимым» (uncatchable; 20.) — оно пройдет сквозь все блоки try выше по стеку, вызывая их обработчики (как catch, так и finally), пока не дойдет до внешнего и не завершит процесс — гуманный аналог abort():

    Caught in catch (1)
    Your message: ! it is a good day to die
        ...UNCATCHABLE at saneex-demo.c:18, code 0
    Now in finally
    
    Uncaught exception (code 0) - terminating. Tag: SaneC's Exceptions Demo
    Your message: ! it is a good day to die
        ...UNCATCHABLE at saneex-demo.c:18, code 0
    UNCATCHABLE rethrown by ENDTRY
        ...at saneex-demo.c:41, code 0

    • наконец, если ввести тройку, то исключение попадет в catchall (35.), где просто будет выведено его сообщение:

    Caught in catchall, message is: Your message: 3 we need more gold
    Now in finally
    End of main()


    Остальные «фичи»



    Потокобезопасность. По умолчанию ее нет, но если у вас нормальный компилятор (не MSVC¹), то C11 спасет отца народов за счет помещения важных переменных в локальную область потока (TLS):


    #define SX_THREAD_LOCAL _Thread_local

    ¹ Последние годы у Microsoft имеются какие-то подвижки на почве open source, но всем по дело идет медленно, хотя и лучше, чем 8 лет назад, так что мы пока держимся.


    sxTag (05.) — строка, которая выводится вместе с непойманным исключением в stderr. По умолчанию — дата и время компиляции (__DATE__ __TIME__).


    Создание SxTraceEntry (записи в stack trace). Есть несколько полезных макросов — оберток над (struct SxTraceEntry) {...}:


    • newex() — этот был в примере; присваивает __FILE__, __LINE__ и код ошибки = errno (что удобно после проверки результата вызова системной функции, как в примере после fgets(); 11.)
      • код меньше 1 становится 1 (ибо setjmp() возвращает 0 только при первом вызове), поэтому catch (0) никогда не сработает
    • msgex(m) — как newex(), но также устанавливает текст ошибки (константное выражение)
    • exex(m, e) — как msgex(), но также прицепляет к исключению произвольный указатель; его память будет освобождена через free() автоматически:

    try {
      TimeoutException *e = malloc(sizeof(*e));
      e->elapsed = timeElapsed;
      e->limit = MAX_TIMEOUT;
      errno = 146;
      throw(exex("Connection timed out", e));
    } catch (146) {
      printf("%s after %d\n", curex().message,
        // читаем через void *SxTraceEntry.extra:
        ((TimeoutException *) curex().extra)->elapsed);
    } endtry

    И, конечно, есть мои любимые designated initializers из все того же C99 (работают в Visual Studio 2013+):


    throw( (struct SxTraceEntry) {.message = "kaboom!"} );

    Выброс исключения:


    • throw(e) — бросает готовый SxTraceEntry
    • rethrow(e) — аналогично throw(), но не очищает текущий stack trace; может использоваться только внутри catch/catchall
    • thrif(x, m) — макрос; при if (x) создает SxTraceEntry с текстом x + m и «выбрасывает» его
    • thri(x) — как thrif(), только с пустым m

    Макросы нужны для удобного «преобразования» результата типичного библиотечного вызова в исключение — как в примере с fgets() (11.), если функция не смогла прочитать ничего. Конкретно с fgets() это не обязательно обозначает ошибку (это может быть просто EOF: ./a.out </dev/null), но других подходящих функций в том примере не используется. Вот более жизненный:


    thri(read(0xBaaD, buf, nbyte));
    // errno = 9, "Bad file descriptor"
    // Assertion error: read(0xBaaD, buf, nbyte);


    …И «особенности реализации»


    Их всего две с половиной (но зато какие!):


    • блок обязан заканчиваться на endtry — здесь происходит завершение процесса при отсутствии обработчика (блока try) выше по стеку
      • эту ошибку компилятор, скорее всего, поймает, ибо try открывает три {, а endtry их закрывает
    • нельзя делать return между try и endtry — это самый жирный минус, но моя фантазия не нашла способов отловить эту ситуацию; принимаются идеи и PR
      • естественно, goto внутрь и наружу тоже под запретом, но разве его кто-то использует? </sarcasm>

    Что касается «половины», то это уже разобранный ранее volatile. «Прием» исключения — это повторный вход в середину функции (см. longjmp()), поэтому, если значение переменной было изменено внутри тела try, то такая переменная не должна использоваться в catch/catchall/finally и после endtry, если она не объявлена как volatile. Компилятор заботливо предупредит о такой проблеме. Вот наглядный пример:


    int foo = 1;
    try {
      foo = 2;
      // здесь можно использовать foo
    } catchall {
      // а здесь уже нет!
    } finally {
      // и здесь тоже!
    } endtry
    // и здесь нельзя!

    С volatile переменную можно использовать где угодно:


    volatile int foo = 1;
    try {
      ...


    Итог: как это работает


    У каждого потока есть два статически-выделенных (глобальных) массива:


    • struct SxTryContext — информация о блоках try, внутри которых мы сейчас находимся — в частности, jmp_buf на каждый из них; например, здесь их два:

    try {
      try {
        // мы здесь
      } endtry
    } endtry

    • struct SxTraceEntry — текущий stack trace, то есть объекты, переданные кодом снаружи для идентификации исключений; их может быть больше или меньше, чем блоков try:

    try {         // один SxTryContext
      try {       // два SxTryContext
                  // ноль SxTraceEntry
        throw(msgex("Первый пошел!"));
                  // один SxTraceEntry
      } catchall {
                  // один SxTraceEntry
        rethrow(msgex("Второй к бою готов!"));
                  // два SxTraceEntry (*)
      } endtry
    } endtry

    Если в коде выше вместо rethrow() использовать throw(), то объектов SxTraceEntry (*) будет не два, а один — предыдущей будет удален (stack trace будет очищен). Кроме того, можно вручную добавить элемент в цепочку через sxAddTraceEntry(e).


    try и другие элементы конструкции суть макросы (— ваш К. О.). Скобки { } после них не обязательны. В итоге, все это сводится к следующему псевдокоду:


    try {                             int _sxLastJumpCode = setjmp(add_context()¹);
                                      bool handled = false;
                                      if (_sxLastJumpCode == 0) {
      throw(msgex("Mama mia!"));        clearTrace();
                                        sxAddTraceEntry(msgex(...));
                                        if (count_contexts() == 0) {
                                          fprintf(stderr, "Shurik, vsё propalo!");
                                          sxPrintTrace();
                                          exit(curex().code);
                                        } else {
                                          longjmp(top_context());
                                        }
    } catch (9000) {                  } else if (_sxLastJumpCode == 9000) {
                                        handled = true;
    } catchall {                      } else {
                                        handled = true;
    } finally {                       }
                                      // здесь действия в finally { }
    } endtry                          remove_context();
                                      if (!handled) {
                                        // как выше с throw()
                                      }

    ¹ Имена с _ в библиотеке не используются, это абстракции.


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

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 41

      +1
      Чего только не придумают, чтобы не ходить на овощную базу (с) (анекдот советских времен)

      Я правильно понял, что само исключение у вас это структура, в которой есть сообщение, строка где его кинули, и указатель еще на что-то?

      Не уловил, можно ли будет посмотреть стектрейс, как скажем в Java, все строки, по которым проходил вызов до точки, где кинули исключение. На мой взгляд, для Java исключений это чуть ли не самое удобное, что дают исключения. Причем без усилий вообще.
        +1
        Я правильно понял, что само исключение у вас это структура, в которой есть сообщение, строка где его кинули, и указатель еще на что-то?

        Да: https://github.com/ProgerXP/SaneC/blob/master/saneex.h#L187


        struct SxTraceEntry {
          int   code;
          char  uncatchable;
          char  file[SX_MAX_TRACE_STRING];
          int   line;
          char  message[SX_MAX_TRACE_STRING];
          void  *extra;
        };

        можно ли будет посмотреть стектрейс, как скажем в Java, все строки, по которым проходил вызов до точки, где кинули исключение

        Конечно, в этом и смысл. На КДПВ справа внизу — именно такой stack trace.


        Uncaught exception (code 1) - terminating.
        Feeling blue today...
            ...at saneex-demo.c:7, code 0
        rethrown by ENDTRY
            ...at saneex-demo.c:11, code 1
        Bye-bye my little pony
            ...at saneex-demo.c:18, code 0
        rethrown by ENDTRY
            ...at saneex-demo.c:19, code 1

        Структура доступна в рантайме, ее можно проитерировать через sxWalkTrace():
        https://github.com/ProgerXP/SaneC/blob/master/saneex.c#L42


        Причем без усилий вообще.

        Да, "без усилий вообще" — это как раз "привычный по другим языкам механизм исключений" для меня. saneex это дает из коробки.

          0
          Да, понял. Спасибо

          Кстати, построение вот этого самого стека, насколько я помню — это как раз то, что в Java сильно снижает производительность при использовании исключений. На хабре даже пару раз упоминали небольшой хак, как все ускорить, кидая исключения без stacktrace.
            +1
            построение вот этого самого стека, насколько я помню — это как раз то, что в Java сильно снижает производительность при использовании исключений.

            Я с низким уровнем в JVM мало знаком, но, как пишут на SO, там используется подход zero-cost exceptions (см. в статье), то есть вся обработка делается в момент поимки исключения (если оно возникает). А Java — высокоуровневый язык с синхронизациями, объектами и прочим, поэтому такая обработка затратна.


            В saneex и "голом" С все наоборот — throw это почти что один longjmp(), который, фактически, только сбрасывает указатель стека (ESP). Затраты на этот сброс околонулевые, что показывают мои замеры, по которым throw/longjmp() в C быстрее, чем throw в C++ (где, как и в JVM, дело не ограничивается только изменением ESP).


            "Построение стека" происходит по мере вызовов try — там копируются параметры исключения (file, message и пр.) в статический массив, плюс вызывается setjmp(). Как раз последний является лимитирующим фактором, но от него избавиться нельзя никак, не уходя от C99. Но даже там счет идет на единицы-десятки мс при 100к повторений.

              0

              Про построение стека интересно — будут ли в нем учтены файлы и вызовы функций по стеку, в которых нет макросов (они ведь макросы?) try-catch? Если да, то как? Если я правильно понял, то они разворачиваются во что-то, использующее FILE (что-то такое), но как провалиться вниз к вызывающему файлу непонятно...

                0
                будут ли в нем учтены файлы и вызовы функций по стеку, в которых нет макросов (они ведь макросы?) try-catch?

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


                Оданко проблемы в этом нет, так как обычно при возникновении исключения вполне достаточно функций с try/catch внутри, а все промежуточные не интересуют.


                Пример:


                  1 #include "saneex.h"
                  2
                  3 int subsub(void) {
                  4   throw(msgex("Test"));
                  5 }
                  6
                  7 int sub(void) {
                  8   subsub();
                  9 }
                 10
                 11 int main(void) {
                 12   try {
                 13     sub();
                 14   } endtry
                 15 }

                Вывод:


                Uncaught exception (code 1) - terminating. 
                Test
                    ...at saneex-demo.c:4, code 0
                rethrown by ENDTRY
                    ...at saneex-demo.c:14, code 1

                Как видите, в стеке только функции main (строка 14 с endtry) и subsub (4 с throw). Функции sub (строки 7-9) нет.

                  0
                  Все в порядке с реализацией и полезностью стека, я лишь пытался понять, насколько много черной магии в его построении :) Получается, что не очень! Спасибо за пояснения.
                    0

                    Магия и стандарт не очень совместимы :)

        0
        Статью ещё читаю, очень интересно, но несколько перегружена хештегами ссылками. Я уже привык к тому, что если кто-то выкладывает контекстную ссылку — там нечто очень желательное к ознакомлению. Но я уже понял свою ошибку и теперь будет читаться быстрее ))
          +4
          Я уже привык к тому, что если кто-то выкладывает контекстную ссылку — там нечто очень желательное к ознакомлению.

          Ну, 9600 бод и все-все-все вполне попадает в категорию "очень желательно" :)


          А если серьезно, то эта статья с претензией на "долгосрочный" справочный материал, поэтому ссылки на стандарт(ы), альтернативы и похожие работы обязаны присутствовать. Но вы ничего не упустите, если не будете по ним ходить, просто придется мне верить на слово.

            +1
            Да, спасибо, просто формат необычный, когда много ссылок.
            Жаль на Хабре нет какого-нибудь автоматического сборщика ссылок, например вы ставите контекстную url — она автоматически превращается по випу как в Википедии (маленький номер в superscript со ссылкой), а внизу статьи полный список с расшифровкой.
            Мне кажется это идея для Хабра — сделать выбираемый пользовательский стиль, который будет показывать статью либо как оформил автор, либо в википедийном стиле.

            Статья, безусловно, в закладки.
              0

              Мне тоже предпросмотра ссылок часто не хватает — было бы неплохо, если бы deniskin взял это на вооружение. Хотя, возможно, это лучше решать на уровне плагина в браузере, а не отдельно взятого сайта.

          +2
          Про более медленную работу fprinf по сравнению с cerr << в общем-то понятно — printf это комбайн на все случаи жизни и под многие типы переменных, ему приходится разбирать строку форматирования. Вывод в cerr, полагаю, пользуется какими-то узкотипизированными форматёрами.
          Но несмотря на это я тоже сторонник тёплого лампового fprintf.
            0
            Про более медленную работу fprinf по сравнению с cerr << в общем-то понятно — printf это комбайн на все случаи жизни и под многие типы переменных, ему приходится разбирать строку форматирования. Вывод в cerr, полагаю, пользуется какими-то узкотипизированными форматёрами.

            Здесь интересно другое (см. таблицу, №3 и сноску): fprintf() в коде бенчмарка работает в 2-3 раза быстрее всегда, кроме одного случая в Visual Studio, когда в цикле выбрасывается исключение — тогда внезапно << начинает работать в 3 раза быстрее fprintf(). Причем это подтвердилось у другого читателя. Я не могу это никак объяснить. И это не повторяется на gcc.

              0

              asm'овый код смотрели?
              << — точно еще нюансы с буферизацией вывода (я на них натыкался) — может оптимизатор хитро как-то срабатывает

            +1
            Насколько понимаю, volatile гарантирует чтение-запись, если потоки находятся на одном процессоре. То есть если вдруг поток перекинули на другой процессор, то надо выполнять семантику release-aquire чтобы обеспечить когерентность кэша. В противном случае есть небольшой шанс прочитать устаревшее значение из кэша на другом процессоре.
              +1
              volatile предписывает только чтение из памяти и сохранение порядка операции, а обновление данных в кэше перед чтением — забота разработчика и/или протокола когерентности.
                0
                что значит
                предписывает только чтение из памяти
                ?
                  0
                  Ниже я сформулировал более корректно: предписывает не оптимизировать операции с данной переменной. При это не гарантирует когерентность кэша даже для однопроцессорной системы.
                  ОС при вытеснении задачи с ядра процессора может сбросить кэш, в контексте другой задачи он не понадобится.
                +1
                Для x86/x86_64 внутри контекста отдельного потока модель памяти CPU гарантируется когерентность кешей между процессорами, на каком бы из ядер/процессоров этот поток не исполнялся.
                  –1
                  Интересует ситуация, если процесс содержит несколько потоков и какой-то поток перешел на другой процессор. Тогда volitale наверное будет мало…
                    0

                    Насколько я понимаю, кэш в x86/x64 сделан так, чтобы программисту не нужно было о нем думать вообще — его как бы нет. За его корректность отвечает ЦП и переключение между ядрами для программы прозрачно. При использовании нескольких потоков это не отменяет необходимость синхронизации (atomic, критические секции и прочее), но сам кэш при этом всегда остается корректным (с точки зрения программы).


                    Беглый поиск выдал вот такой вопрос-ответ на SO:


                    x86 CPUs use a variation on the MESI protocol (MESIF for Intel, MOESI for AMD) to keep their caches coherent with each other (including the private L1 caches of different cores). A core that wants to write a cache line has to force other cores to invalidate their copy of it before it can change its own copy from Shared to Modified state.

                    You don't need any fence instructions (like MFENCE) to produce data in one thread and consume it in another on x86, because x86 loads/stores have acquire/release semantics built-in. You do need MFENCE (full barrier) to get sequential consistency.

                    То же самое относится к многопроцессорным системам.

                      +1
                      Я ответил именно на этот вопрос. Не важно на каком ядре, не важно на каком процессоре. В пределах контекста исполнения отдельного потока когерентность всех кешей — проблема самого процессора. Для программы число ядер/процессоров прозрачно.
                      volitale в статье нужен, поскольку, как красочно подметил автор, setjmp может быть возвращена несколько раз, что бы компилятор был готов к такому исходу.
                  +5
                  квалификатор volatile (дословно — «летучий»). Он заставляет компилятор всегда помещать переменную в стек

                  volatile предписывает не оптимизировать операции с данной переменной, а в стек кладет ее компилятор, потому-что она локальная не static переменная.

                  С нетерпением жду статьи с опровержением и опровержением опровержения тезиса данной статьи.
                    +2
                    volatile предписывает не оптимизировать операции чтения/записи из памяти с данной переменной. И соответственно компилятор вынужден её класть либо в heap, либо в стек. Поскольку её размещение в регистре и как раз и есть оптимизация в рамках той абстрактной машины, которую должен реализовывать язык. Самое близкое, наверное, что бы много не читать: ISO/IEC 9899:201x (N1548) страница 121, замечание 134. В вольном переводе:
                    Декларация volatile может быть использована для описания объекта, соответствующего memory-mapped порту ввода-вывода или объекту к которому осуществляется доступ из прерываний. Действия на объявленных таким образом переменных не должны быть оптимизированы или переупорядочены, за исключением разрешенных правилами оценки выражений
                    Заметьте, достаточно одного volatile, чтобы переменная соответствовала чему-то в памяти (т.е. не оптимизировалась размещением в регистре). Про абстрактную машину и правила оценки глава 5.1.2, но там нужно вдумчивое чтение.
                      0
                      Спасибо за ссылку/дополние, но немного смутило
                      достаточно одного volatile, чтобы переменная соответствовала чему-то в памяти

                      А можно пример когда одного volatile недостоточно для неоптимизации размещением в регистре?
                        0

                        Имеется в виду, что при наличии volatile не требуется других модификаторов/факторов — факт наличия только volatile является достаточной гарантией, что переменная не попадет в регистр и к ней не будут применены другие описанные оптимизации.

                    +1

                    Спасибо за статью. Обожаю такие подробные обзоры и погружения в детали.

                      0
                      Осталось разобраться с управлением памятью…
                        +1
                        Спасибо, статья отличная, с разборами кода, с заглядыванием под капот чистого Си.
                        И с временем публикации тоже все правильно получилось, на фоне потока непрерывных новостей о коронавирусе она вселяет настоящий оптимизм.
                        «Вирусы приходят и уходят, а Си вечно молодой, вечно пьяный.»
                          +1
                          на фоне потока непрерывных новостей о коронавирусе она вселяет настоящий оптимизм.

                          А я вот смотрю на счетчик просмотров и понимаю, что теперь Хабр уже точно "не торт" :/ Верю и надеюсь, что это временно.

                            0
                            Все будет хорошо, и Хабр вновь будет «торт» ;)
                          0
                          нельзя делать return между try и endtry — это самый жирный минус, но моя фантазия не нашла способов отловить эту ситуацию; принимаются идеи и PR

                          B gcc можно поставить хук на выход из ф-ции с момщью -finstrument-functions
                          и так ловить
                            0

                            Имелось в виду использование стандартных переносимых конструкций, как, например, тройное { (как сделано для try и endtry) или #define return abort (но макросы не рекурсивны).

                              0
                              стандартных переносимых конструкций, как, например, тройное { (как сделано для try и endtry) или #define return abort (но макросы не рекурсивны).

                              А можно про это подробнее, не совсем понял что вы имели ввиду.
                                +1

                                Рассмотрим две проблемы из статьи:


                                блок обязан заканчиваться на endtry

                                Задача компилятора/среды программирования — максимально разгрузить программиста от контроля за мелочами (синтаксиса, платформы и прочего). В контексте моей библиотеки, может быть такой случай использования:


                                try {
                                  func();
                                } endtry   // <<<

                                Допустим, код выше (где try и endtry это макросы) разворачивается в такой:


                                if (xxx)    // "try"
                                {
                                  func();
                                }
                                if (!handled) ...   // "endtry'

                                Что будет, если программист забудет endtry?


                                if (xxx)    // "try"
                                {
                                  func();
                                }

                                Последней строчки нет. При этом в endtry происходит очистка стека и его пропуск является фатальной ошибкой, которую не поймать даже в рантайме (с точки зрения saneex блок try продолжает быть открытым до завершения процесса). А забыть endtry очень легко.


                                Поэтому в saneex (см. исходники):


                                #define try     {{{ if (_sxEnterTry2( setjmp(*_sxEnterTry()) ))
                                #define endtry  _sxLeaveTry(__FILE__, __LINE__); }}}

                                Обратите внимание на {{{ и }}}. Теперь, если пропустить endtry, код будет таким:


                                {{{ if (xxx)    // "try"
                                {
                                  func();
                                }

                                Это, очевидно, является синтаксической ошибкой, о чем компилятор сразу предупредит. Сравните с:


                                {{{ if (xxx)    // "try"
                                {
                                  func();
                                }
                                if (!handled) ... }}}  // "endtry"

                                Забыть endtry и не заметить этого теперь можно только если пропустить 3 закрывающие скобки помимо собственно endtry, а это сложно сделать.


                                нельзя делать return между try и endtry

                                Теперь такой пример:


                                try {
                                  return func();
                                } endtry

                                Это является точно такой же фатальной ошибкой, т.к. вызывает повреждение стека. Скобки нам уже не помогут, потому что endtry на месте. Можно было бы сделать что-то подобное:


                                #define try    #define return abort();

                                И тогда:


                                try {
                                  abort(); func();
                                } endtry

                                В этом случае в рантайме при попытке выполнить такой блок гарантированно получили бы падение. Еще можно было бы #define return $#@!, чтобы вызвать синтаксическую ошибку — однако макросы не раскрываются рекурсивно, так что это не сработает и за пропущенным return, в отличии от endtry, приходится следить программисту.


                                Вот это я и имел в виду.

                                  0
                                  Спасибо за развернутый ответ!
                                  Еще можно было бы #define return $#@!, чтобы вызвать синтаксическую ошибку — однако макросы не раскрываются рекурсивно, так что это не сработает и за пропущенным return, в отличии от endtry, приходится следить программисту.

                                  Рекурсия не получится но можно использовать include
                                  К примеру:
                                  test.c:
                                  #define TRY #include "inc.h"
                                  
                                  int main(){
                                  TRY
                                  return 0;
                                  }


                                  inc.h:
                                  
                                  #define return $#@!
                                  

                                  gcc -E test.c:
                                  
                                  int main(){
                                   #include "inc.h"
                                  return 0;
                                  }
                                  
                                    0

                                    Попробуйте скомпилировать #define с # внутри, без -E — у меня ругается:


                                    error: stray ‘#’ in program
                                    #define TRY #include ...
                          0

                          Спасибо за статью и стиль изложения.
                          Единственное что смутило: int i{ vec.at(4) }; — это C++ головного мозга?

                            0
                            Единственное что смутило: int i{ vec.at(4) }; — это C++ головного мозга?

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

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое