Чи бувало у вас таке, що в неділю ввечері вас починає мучити питання: “А скільки ж все-таки способів надрукувати "Hello World" у консоль в C++?”.
Сподіваюсь, ні, бо це вже як мінімум профдеформація. Але я задався таким питанням, і зрозумів, що немає жодного джерела,
яке відповідає на нього. Тому я вирішив зробити таке джерело сам.
Оскільки C++ - це тепер не просто мова програмування, а ще й мова метапрограмування, розглянемо два окремі випадки:
друк "Hello World" в рантаймі та на етапі компіляції.
Моєю метою було порахувати всі справді різні способи. І це складніше, ніж здається.
Бо врешті майже все зводиться до чогось, що викликає write(2) через різну кількість шарів абстракцій,
причому часто різниця між способами мікроскопічна. Я використав наступний підхід:
| |
Весь код протестований на Fedora 44 (ядро 7.0, x86-64) зі стеком GCC 16.1, Clang 22.1, glibc 2.43. Усі сніпети лежать в репозиторії блогу, разом зі скриптами для запуску.
Я тут розглянув як методи, що чітко відповідають C++26, так і методи, які працюють специфічно на Linux x86-64.
Рантайм
Спочатку код, який повністю відповідає C++26, потім код, який працює тільки на POSIX системах, далі POSIX розширення, а наостанок Linux-специфічний код.
Стандартний C++
Канонічний набір
Це ті способи, які зустрічаються в 99% випадків.
#1. std::cout::operator<<
| |
Всім відомо, що \n сам по собі не флашить буфер. Якщо треба flush, то треба замінити \n на std::endl:
| |
або викликати явний flush():
| |
Чомусь у більшості туторіалів для початківців використовують std::endl.
Хоча, на мою думку, в більшості випадків він не потрібен, і краще використати просто \n.
#2. printf()
| |
printf парсить format string у пошуках %, тому технічно тут є зайва робота.
Хоча компілятори давно це оптимізують до puts("Hello World").
Відступ про std::ios_base::sync_with_stdio(false)
Маленький відступ, знайомий людям, що займались competitive programming.
“Нове” iostream-based API в C++ додавали із сумісністю з C в пріоритеті.
У тому плані, що код, який вже використовував printf (або будь-яке інше C stdio I/O) міг без зайвих налаштувань почати використовувати std::cout з гарантованим очікуваним порядком виводу. Це важливо, бо C та C++ код часто лінкується разом.
Тому, за замовчуванням, стандартні C++ потоки синхронізовані з відповідними C потоками: std::cout з stdout, std::cin з stdin, std::cerr і std::clog з stderr (плюс їхні wide-аналоги). Коли синхронізація увімкнена, C++ потоки можуть ділити буфер з відповідним FILE*. Два послідовні різні виклики operator<< та printf відпрацюють у тому порядку, в якому написані. Тут, до речі, на мою думку, C++ ламає принцип zero-overhead.
Цю поведінку можна виключити, викликавши std::ios_base::sync_with_stdio(false).
Важливо: виклик має бути до будь-яких I/O операцій, інакше поведінка implementation-defined.
Після цього C++ потоки отримують власний незалежний буфер.
Але якщо після цього міксувати operator<< та printf, порядок виведення більше не буде гарантований,
бо кожен механізм буферизує незалежно.
#3. fprintf()
| |
До речі, стандартом напряму чітко визначено, що printf - це fprintf(stdout, ...).
#4. puts()
| |
Як на мене, це найкращий спосіб, коли треба просто надрукувати рядок у термінал без форматування.
Функція сама додає \n.
#5. fputs()
| |
На відміну від puts(), ця функція сама не додає \n.
#6. std::print() - C++23
| |
Функція побудована на базі std::format, що є типобезпечним. Також вона не тягне за собою весь iostream.
На мою думку, це те, що мало бути в мові вже дуже давно, а не з 2023 року.
Маленька деталь: це перевантаження std::print пише в FILE* stdout, а не в std::cout.
Тут можна побачити, що комітет розуміє, що iostream не був дуже вдалим рішенням у ретроспективі.
#7. std::println() - C++23
| |
Те саме, що std::print, але з автоматично доданим \n наприкінці.
#8. std::print(stdout, …) - C++23
| |
Це перевантаження приймає будь-який FILE*.
#9. std::println(stdout, …) - C++23
| |
Те саме, що #8, але з автоматично доданим \n наприкінці.
#10. std::print(std::ostream&, …) - C++23
| |
А це перевантаження приймає std::ostream&.
#11. std::println(std::ostream&, …) - C++23
| |
Те саме, що #10, але з автоматично доданим \n наприкінці.
Рівень нижче: байти й символи
#12. std::ostream::write()
| |
Прямий запис n байтів. Працює на будь-якому std::ostream, і все аналогічно йде через буфер.
#13. std::streambuf::sputn()
| |
Те, що std::cout.write робить усередині. std::ostream::write спершу створює sentry (перевіряє стан потоку), а тоді викликає sputn на std::streambuf.
#14. std::ostream::put()
| |
Це кладе в буфер по одному символу за раз. У ядро це піде одним write під час флашу.
#15. std::streambuf::sputc()
| |
Те саме, що sputn, тільки посимвольно, і рівно те, як cout.put імплементовано всередині.
#16. fwrite()
| |
#17. putchar()
| |
#18. putc()
| |
Те саме, що putchar, але з явним FILE*. putc за стандартом може бути макросом.
#19. fputc()
| |
Те саме, що putc, але гарантовано функція.
Форматування як окрема операція: <format>
std::cout << std::format("…") я не рахую, бо по суті це просто operator<<.
#20. std::format_to()
| |
Форматує одразу в output iterator.
#21. std::format_to_n()
| |
Те саме, що format_to, але з обмеженням на кількість символів. Використовується, коли повідомлення
форматується в буфер фіксованого розміру.
iostream + алгоритми STL
Навіщо писати цикл, якщо можна зібрати докупи три шаблони?
#22. STL-алгоритм + output iterator
| |
Замість std::ranges::copy тут так само спрацюють std::copy, std::for_each, std::ranges::for_each.
А ось важлива деталь: який саме output iterator взяти, визначає, через що будуть передаватись байти. std::ostream_iterator<char> передає кожен символ через operator<< (тобто formatted output, як у #1). А std::ostreambuf_iterator<char> пише прямо в streambuf через sputc, оминаючи весь шар форматування (як sputc, #15):
| |
А якщо в cout пишуть кілька потоків одночасно?
#23. std::osyncstream - C++20
| |
osyncstream накопичує output у власному буфері й атомарно передає його в цільовий потік під час деструкції.
Це зроблено тому, що якщо у вас кілька потоків пишуть у ostream без синхронізації, то їхні рядки можуть перемішатися.
Виклик printf() атомарний (stdio бере лок FILE* на час виклику), і std::cout::operator<< на практиці теж, бо libstdc++ за замовчуванням ходить через той самий лок. Щоправда, стандарт атомарності одного << не гарантує, а гарантує лише відсутність data race.
Але std::cout << "Hello" << "World" - це вже 2 окремі виклики оператора, і між ними може вклинитись
std::cout::operator<<, виконаний в іншому потоці.
std::osyncstream склеює всю послідовність operator<< в один атомарний виклик. По суті це те саме, що зібрати рядок у std::ostringstream, а потім один раз зробити std::cout << stream.str().
stderr і широкі потоки
#24. std::cerr::operator<<
| |
Той самий operator<<, але інший потік. std::cerr - це стандартний потік помилок (дескриптор 2 на Linux).
Існує він для зручності, щоб можна було легко розділяти через перенаправлення потоку помилки і звичайний output.
У cerr виставлений прапор unitbuf, тому він флашиться після кожної операції виведення.
Бо зазвичай ми хочемо дізнатися про помилку одразу, як вона виникла, а не коли буфер
вирішить зафлешитись.
#25. std::clog::operator<< у stderr, але з буфером
| |
Той самий cerr, але без виставленого unitbuf. Задуманий він для діагностичних повідомлень,
які не є “терміновими”.
Тут можна було ще окремо зарахувати кожен перелічений вище метод cout ще 2 рази (один для cerr, один для clog),
але я не буду.
#26. fprintf(stderr, …) - C stdio у stderr
| |
Аналогічна функціональність до cerr, але в cstdio.
#27-29. Широкі потоки: std::wcout, std::wcerr, std::wclog
| |
Ця трійця віддзеркалює cout/cerr/clog: wcout пише в stdout, wcerr/wclog - у stderr. Різниця у тому, що широкі потоки приймають wchar_t і конвертують його в char через codecvt локалі, щось типу use_facet<codecvt<...>>(getloc()). Для ASCII конвертація тривіальна. На виході той самий write(1, "Hello World\n", 12).
Взагалі локалі - це одна з проблем C++, яка, серед іншого, особливо боляче вистрілила в регулярних виразах. Є мем, що для деяких регулярних виразів швидше запустити Python script, ніж чекати, поки відпрацює std::regex. Це черговий приклад порушення принципу zero-overhead.
#30-33. Широкі потоки: нижчі точки входу (wcout.write тощо)
Усе, що має cout (#12-#15), має й wcout, просто шаблонізоване на wchar_t.
Наступні чотири методи відповідають #12-#15: wcout.write (#30), wcout.rdbuf()->sputn (#31), wcout.put (#32), wcout.rdbuf()->sputc (#33). Тут можна було б ще зарахувати wcerr/wclog і відповідні методи окремо, але я не буду.
#34-39. Широкий C stdio: wprintf і компанія
| |
Аналогічно до iostream, cstdio має власну шістку широких функцій (точніше, навпаки): wprintf (#34), fwprintf (#35), fputws (#36), putwchar (#37), putwc (#38), fputwc (#39). Усі конвертують wchar_t через wcrtomb локалі. Для ASCII на виході знову write(1, …, 12).
Нехай надрукує інший процес (стандартний C)
#40. system()
| |
Це запускає echo, яке друкує “Hello World”.
Так робити не треба, бо це довго і небезпечно через shell injection.
output як побічний ефект діагностики
Тут “Hello World” опиняється в терміналі не тому, що ми його друкуємо, а тому, що бібліотека чи рантайм ним повідомляє про щось у stderr.
#41. Непійманий throw
| |
Виняток ніхто не ловить -> викликається std::terminate -> рантайм друкує what() у stderr і вбиває процес:
| |
Це вже хак, але технічно наш рядок опинився в терміналі. Точний текст цього повідомлення implementation-defined. Його видає стандартна бібліотека.
#42. assert()
| |
| |
Працює, тільки поки не задефайнено NDEBUG. Інакше assert розкривається у ніщо і Hello World зникає.
#43-45. contract_assert, pre, post - C++26 Contracts
| |
Три нові конструкції з Contracts в C++26. contract_assert - це еволюція assert.
| |
Усі три проходять через обробник порушень контракту, а не через abort, і його поведінку можна перемикати
прапором компілятора -fcontract-evaluation-semantic=[ignore|observe|enforce|quick_enforce].
Загалом система дуже гнучка і заслуговує на окремий топік, яких зараз безліч.
GCC 16 (з -fcontracts) під дефолтним enforce друкує в stderr:
| |
Ключова відмінність між трьома - це поле assertion_kind: assert, pre чи post.
Поки що Contracts вміє лише GCC. В стабільній версії Clang їх ще немає.
#46. perror()
| |
perror друкує в stderr рядок, двокрапку й опис поточного errno. Оскільки у прикладі errno обнулено, то опис буде “Success”:
| |
Хак? Хак. Але що ви мені зробите)
Проміжний підсумок: 46 способів. І це все стандартний C++26.
Бонус
Хочеться ще розглянути методи, які не належать до стандарту, але також можуть використовуватись.
POSIX
Код, що відповідає стандарту POSIX та працює на всіх системах, що його реалізують.
Прямий I/O повз буфери
#47. write()
| |
Це той самий write(2), до якого зводиться майже все інше в цій статті.
#48. writev()
| |
Збирає дані з кількох окремих буферів в один системний виклик.
#49. dprintf()
| |
printf для файлових дескрипторів. Форматує як printf, але пише напряму у fd, без FILE*.
А тепер почесна згадка, яка не йде в залік. Є ще pwrite() - позиціонований запис за зміщенням:
| |
Проблема в тому, що pwrite вимагає seekable дескриптор. Якщо записувати у файл
(./a.out > out.txt), то це спрацює. А якщо в термінал чи pipe, то буде ESPIPE (Illegal seek), і нічого не надрукується, тому цей спосіб не зараховується.
Через файлову систему
До дескриптора можна дотягнутися й через файлову систему, просто відкривши потрібний шлях.
#50. open("/dev/tty") + write()
| |
/dev/tty - це контролюючий термінал процесу, незалежно від того, куди перенаправлено stdout. Запустіть ./a.out > /dev/null, і ви все одно побачите “Hello World” у терміналі, бо він пише повз перенаправлення. Цей спосіб вимагає наявності контролюючого термінала. Під час тесту в headless-середовищі без tty open повертає -1, тому я перевіряв його під справжнім псевдотерміналом.
Споріднена цікавинка, що не йде в перелік, бо вже й не працює: ioctl(fd, TIOCSTI, &c) додає символ не у output термінала, а в його чергу вводу. Сучасні ядра вимикають TIOCSTI за замовчуванням (CONFIG_LEGACY_TIOCSTI) і вимагають CAP_SYS_ADMIN.
Нехай надрукує інший процес (POSIX)
#51. execlp()
| |
Це замінює процес на echo. Після вдалого exec “нашого” коду буквально більше не існує.
#52. fork() + execvp()
| |
Класичний Unix-патерн і принципова відмінність від попереднього: ми форкаємось, child стає echo, а parent залишається живим і чекає на завершення виконання child. Зверніть увагу на _exit(127) (не exit()) після exec. Якщо exec раптом зафейлиться, то child не має провалитися далі в parent логіку.
Ви можете мене спитати. Чому exec способів лише два, а не шість? Сімейство exec* (execl, execlp, execle, execv, execvp, execve) різниться тільки тим, як передаються аргументи. Усі вони зводяться до одного системного виклику execve. Тому я вирішив не нагліти тут і не рахувати види exec, а рахувати тільки патерн роботи з процесом: замінити себе (#51) чи форкнутись і пережити child (#52).
#53. posix_spawn()
| |
Стандартизована альтернатива зв’язці fork + exec в одному виклику. На додачу, у випадку, коли fork дорогий, posix_spawn може бути ефективнішим, бо реалізований через легші примітиви (на Linux - через clone/vfork).
Чому fork може бути дорогим? Інтуїтивно fork майже безкоштовний, через те, що він не копіює фізичну пам’ять, бо працює через copy-on-write (COW). Водночас його вартість залежить від розміру page tables: ядро мусить продублювати всі батьківські PTE, позначити кожну writable сторінку як read-only для COW і зробити TLB shootdown по всіх ядрах, на яких виконувався процес. Для процесу з великим адресним простором це вже відчутно. posix_spawn через vfork/clone(CLONE_VM|CLONE_VFORK) усього цього уникає: child позичає адресний простір parent, тож дублювати таблиці сторінок не треба.
Асинхронний input-output
#54. aio_write() - POSIX AIO
| |
Асинхронний input-output. Це ставить запис у чергу й чекає завершення через aio_suspend.
На glibc POSIX AIO реалізований пулом helper-тредів, що роблять звичайний синхронний I/O: на не-seekable дескриптор (термінал, pipe) тред намагається зробити pwrite, що призводить до ESPIPE, а тоді робить fall-back на той самий write(1, "Hello World\n", 12).
#55. lio_listio() - батч POSIX AIO
| |
Аналогічно до попереднього методу, але замість однієї операції цей метод приймає цілий список aiocb і сабмітить його одним викликом. LIO_WAIT ще змушує цей потік заблокуватись, доки весь список не відпрацює.
І ще одна почесна згадка, яка не йде в залік: send().
| |
send - це write для сокетів. Якщо ваш stdout - це сокет (наприклад, програму запустили з-під inetd чи socat), це спрацює. Я перевірив, підставивши сокет на дескриптор 1, і це спрацювало. Але в звичайному терміналі це призводить до ENOTSOCK. Тому як окремий спосіб - не зараховую.
_unlocked - ті самі функції без внутрішнього локу
Кожен виклик stdio за замовчуванням блокує FILE* заради потокобезпечності. Родина _unlocked блокування не робить, у цьому вся різниця. Це швидше, але треба гарантувати, що в цей потік ніхто інший не пише одночасно.
putc_unlocked/putchar_unlocked - це частина POSIX. Решта (зокрема всі широкі) - це розширення glibc, але перелічу я все тут, бо, знову ж таки, що ви мені зробите.
#56-60. Вузькі: putchar_unlocked (#56), putc_unlocked (#57), fputc_unlocked (#58), fputs_unlocked (#59), fwrite_unlocked (#60) - двійники #17/#18/#19/#5/#16 без локу.
| |
#61-64. Широкі (glibc): fputws_unlocked (#61), putwchar_unlocked (#62), putwc_unlocked (#63), fputwc_unlocked (#64) - двійники #36/#37/#38/#39 без локу.
| |
Проміжний підсумок: 64 способи.
Розширення
POSIX - це не вся Unix-екосистема. Є ще купа розширень, яких немає в жодному стандарті.
<err.h> (BSD) і <error.h> (GNU)
BSD-родина <err.h> дає чотири такі функції, а GNU-розширення <error.h> дає ще дві.
#65-68. err(), warn(), errx(), warnx() - BSD <err.h>
| |
Четвірка різниться двома моментами: чи додавати : strerror(errno) (як perror) і чи виходити з програми. warn/err додають strerror, warnx/errx - ні. err/errx наприкінці викликають exit(), warn/warnx - ні.
#69-70. error(), error_at_line() - GNU <error.h>
| |
error(status, errnum, …) за errnum != 0 додає strerror, за status != 0 виходить. error_at_line робить те саме, плюс додає префікс файл:рядок:.
Проміжний підсумок: 70 способів.
Суто Linux
Через procfs
stdout - це файл, і його можна відкрити за шляхом у файловій системі. На Linux /dev/stdout - це симлінк на /proc/self/fd/1. Сам POSIX цього шляху не стандартизує. На BSD, наприклад, /dev/stdout теж є, але через інший механізм.
#71. std::ofstream("/dev/stdout")
| |
Те саме можна зробити мовою C через fopen("/dev/stdout", "w") + fprintf або через інші шляхи до того ж дескриптора: /dev/fd/1 чи /proc/self/fd/1. Я зараховую це як 1 метод “відкрити fd через файлову систему”.
syscall
#72. syscall(SYS_write, …)
| |
Це обходить навіть libc обгортку write() і викликає системний виклик за його номером.
#73. Inline assembly - x86-64
| |
Найнижчий рівень, доступний з C++: сама інструкція syscall. rcx і r11 у списку clobber-ів не випадкові: інструкція syscall затирає їх, зберігаючи в них RIP і RFLAGS відповідно. memory у clobber-ах каже компілятору не тримати значення пам’яті в регістрах через межу asm.
Перенесення даних силами ядра
Наступні три способи цікаві тим, що дані рухаються до stdout усередині ядра, майже не торкаючись нашого userspace, а фінальний output робить не write, а власний системний виклик.
#74. sendfile() з memfd_create()
| |
memfd_create робить анонімний файл з ім’ям “hello”, що живе в RAM і видимий в /proc/self/fd. write заповнює цей файл, а тоді sendfile копіює дані з нього в stdout в kernel-space. Для більшого приколу, цей memfd можна заповнити не через write, а через mmap + memcpy.
#75. splice() через pipe
| |
splice переміщує дані між дескрипторами через ядро, без копіювання в userspace. Один з дескрипторів обов’язково має бути pipe. Output у stdout тут робить сам системний виклик splice. Є схожі методи типу vmsplice (мапить сторінки userspace в pipe) і tee (дублює дані між двома pipe).
Ще є copy_file_range, який теж копіює дані між двома дескрипторами, але обидва дескриптори мусять бути звичайними файлами. У термінал чи pipe цей метод копіювати не вміє.
#76. io_uring
| |
Найсучасніший Linux I/O API. Submission queue, completion queue, buffer ring, спільні між ядром і userspace - усе це придумано, щоб максимально ефективно робити велику кількість I/O операцій. Для “Hello World”, як бачимо, воно також підходить. Тут немає синхронного write взагалі. I/O ядро виконує з нашого SQE, а ми лише сабмітимо й чекаємо завершення. Це чудово видно в strace: жодного write(1, …) там немає, натомість лише io_uring_setup і io_uring_enter, усередині якого ядро саме й робить запис:
| |
Підсумок рантайму: 76 способів.
Compile-time - програма навіть не запускається
У цьому розділі розглянемо, як змусити “Hello World” з’явитися під час компіляції, а не виконання.
Стандартний C++
#77. static_assert - C++11
| |
| |
Найбільш прямий спосіб змусити компілятор надрукувати те, що ти хочеш. Також це можна відкласти до інстанціації шаблону через value-dependent вираз:
| |
N != N залежить від параметра шаблону, тому перевірку відкладено до інстанціації. Сучасні GCC/Clang завдяки CWG2518 уже не падають і на незалежному static_assert(false).
#78. [[deprecated]] - C++14
| |
Компіляція проходить, але з попередженням:
| |
#79. [[nodiscard("…")]] - C++20
| |
Компіляція проходить, але якщо проігнорувати значення, що повертається (а ми саме це й робимо), то буде попередження:
| |
Можливість додавати причину для [[nodiscard]] додали в C++20, сам [[nodiscard]] в C++17.
#80. = delete("…") - C++26
| |
| |
Можливість указати причину видалення функції прийняли аж у C++26, GCC 16 це вже підтримує.
#81. throw під час компіляції - C++26
У C++26 кидати винятки можна вже під час компіляції, і якщо виняток виходить за межі constexpr-виразу, то компілятор зобов’язаний це продіагностувати. GCC 16 при цьому виводить what() просто в текст помилки:
| |
| |
Фактично, компілятори вже вміють виконувати велику частину C++ коду під час компіляції.
Невеличке застереження: це поки що вміє лише GCC. Clang 22 ще не реалізував кидання винятків у константних обчисленнях (P3068). Він просто відкидає throw як неконстантний вираз, не доходячи до what().
#82. #warning - C++23
| |
| |
До C++23 це було розширенням GCC і Clang; тепер це стандарт (P2437R1).
#83. #error - C++98
| |
| |
Стандартна директива препроцесора з C++98.
#84. #include "Hello World"
| |
| |
Ще один output як побічний ефект діагностики, тільки тепер від препроцесора. Препроцесор шукає файл із таким іменем, не знаходить і падає з фатальною помилкою. Ім’я в лапках може містити пробіл, тож "Hello World" - це цілком легальний хедер. Теж трохи хак, але що поробиш)
Компілятор-специфічні
#85. #pragma message
| |
| |
На відміну від #warning і #error, #pragma message у стандарті немає. Це розширення, яке підтримують GCC, Clang і MSVC.
#86. __attribute__((warning(...))) - лише GCC
| |
| |
Тут є цікавий технічний нюанс, на який я натрапив під час перевірки. Цей атрибут спрацьовує, тільки якщо виклик f() доживає до пізніх стадій компіляції. На -O0 усе гаразд, попередження є. А на -O2 компілятор інлайнить порожню f() і викидає виклик ще до того, як атрибут встигне спрацювати, тому попередження зникає. Тобто наявність Hello World залежить від рівня оптимізації.
#87. __attribute__((error(...)))
| |
| |
Аналогічно до warning, але якщо виклик доживає до кодогенерації, то компіляція падає з нашим повідомленням.
На відміну від #86, я тут залишив f() без тіла, бо без LTO невизначену функцію неможливо заінлайнити, тому виклик гарантовано доживає, і помилка спрацьовує на будь-якому рівні оптимізації.
#88. __attribute__((unavailable("…")))
| |
| |
unavailable спрацьовує на рівні семантичного аналізу, тобто на будь-яке використання імені, тому не залежить від оптимізації.
#89. __attribute__((diagnose_if(…))) - лише Clang
| |
| |
Clang дозволяє повісити на функцію умовну діагностику з власним текстом. GCC просто ігнорує атрибут (warning: 'diagnose_if' attribute directive ignored).
Асемблерні директиви
#90. asm(".error …")
| |
| |
Рядок друкує вже не компілятор, а GNU as, коли натрапляє на директиву .error.
Clang з інтегрованим асемблером має аналогічну поведінку: error: Hello World.
#91. asm(".warning …")
| |
| |
Те саме, але рівень warning: об’єктний файл усе одно збереться, асемблер лише попередить.
#92. asm(".print …")
| |
| |
Асемблер, на відміну від решти цієї секції, друкує рядок у stdout, а не у stderr.
Підсумок compile-time: 16 способів.
Фінал: усі дороги ведуть до write(2)
Загальний підсумок: 92 способи надрукувати “Hello World\n” у консоль у C++ на Linux. З них 54 - стандартний C++.
| Категорія | Кількість |
|---|---|
| Стандартний C++26 (рантайм) | 46 |
| POSIX (+ glibc unlocked) | 18 |
| Розширення (BSD/glibc) | 6 |
| Суто Linux | 6 |
| Compile-time (стандартний C++) | 8 |
| Compile-time (нестандартні) | 8 |
| Всього | 92 |
Врешті, майже всі рантайм методи зводяться до одного системного виклику write(2). І лише чотири мають власний системний виклик: writev, sendfile, splice та io_uring.
Скільки буферів між тобою і ядром
Окрема тема, навколо якої багато плутанини: скільки буферів стоїть між викликом і ядром:
| Спосіб | Буферизація | Коли реально йде write(2) |
|---|---|---|
write, writev, dprintf, syscall, asm, /dev/tty | немає | одразу, на кожен виклик |
C stdio: printf, fprintf, puts, fputs, fwrite, putchar, putc, fputc, print, println (і _unlocked-двійники) | буфер stdout (FILE*) | у термінал - на кожен \n; у файл/pipe - коли буфер повний або при виході |
iostream: cout <<, .write, .put, sputn, sputc, STL-ітератори | у дефолті - той самий буфер stdout; з sync_with_stdio(false) - власний | так само, плюс явний flush / endl |
Тобто прямі способи пишуть у ядро одразу, буферизовані флашаться або на \n (у термінал), або коли буфер заповниться, або під час нормального виходу з програми (exit флашить всі stdio-буфери й викликає деструктори статичних cout).
Ще хочеться розказати про нюанс з cerr і clog (#24 і #25). Прийнято вважати, що cerr небуферизований, а clog буферизований.
std::cerr має виставлений прапор unitbuf, тому він флашиться після кожної операції виводу. std::clog цього прапора не має. Здавалося б, clog мав би накопичувати output, але по дефолту (sync_with_stdio(true)) обидва потоки пишуть у C-шний stderr, а він сам по собі небуферизований. Тому на POSIX платформах насправді обидва пишуть одразу. Я перевірив через strace (рядок "Hello" << " " << "World" << "\n" - це 4 операції):
| |
Різниця з’являється, тільки якщо від’єднати iostream від stdio:
| |
Ось тепер clog справді складає все в буфер і флашить одним write наприкінці, а cerr через unitbuf усе одно флашить на кожній операції.
Врешті, якщо запустити способи, що базуються на write, то strace -e write всюди покаже однаковий результат з точністю до дескриптора:
| |
Навіть непійманий throw (#41) врешті просто пише в stderr: (write(2, "terminate called…", 48), далі write(2, "Hello World", 11)).
Отакі справи, малята. Тому я не розумію людей, які кажуть, що C++ роздутий. Все дуже просто і лаконічно.