Як зробити свій код кроссплатформенным

Можливо, хтось, прочитавши заголовок, запитає: «Навіщо робити щось зі своїм кодом?! Адже С++ кросплатформна мова!». Загалом це так… але тільки поки ніде немає зав'язок на специфічні можливості компілятора та цільової платформи…

У реальному житті розробники, які вирішують конкретне завдання під конкретну платформу, рідко запитують: «Чи точно це відповідає Стандарту С++? А раптом це розширення мого компілятора». Вони пишуть код, запускають складання і лагодять місця, на які вилаявся їх компілятор.

У результаті отримуємо додаток, який, до певної міри, «заточений» під конкретний компілятор (і навіть під його конкретну версію!) і цільову ОС. Більш того, через мізерність стандартної бібліотеки С++ деякі речі просто неможливо написати, не скориставшись специфічним API системи.

Так було й у нас у Тензорі. Ми писали на MS Visual Studio 2010. Наші продукти були 32-бітними Windows-додатками. І, зрозуміло, код був пронизаний всілякими зав'язками на технології від Microsoft. Одного разу ми вирішили, що настав час освоювати нові горизонти: настав час навчити НВІС працювати під Linux та іншими ОС, настав час спробувати перейти на інше «залізо» (POWER).

У цьому циклі статей я розповім, як ми зробили свої продукти справжніми кроссплатформенными додатками; як змусили їх працювати на Linux, MacOS і навіть під iOS та Android; як запустили свої програми на безлічі апаратних архітектур (x86-64, POWER, ARM та інші); як навчили працювати на big-endian машинах

зробити
Основа всіх наших продуктів — власний фреймворк «Платформа НВІС» (далі за текстом — «Платформа»), який за масштабністю можна порівняти з Qt. У платформі є практично все, що потрібно розробнику: від простих функцій швидкого перетворення числа у рядкову форму допотужного стійкого сервера додатків.

На базі Платформи наші розробники реалізують свої продукти (навіть мобільні додатки), які вирішують різноманітні бізнес-завдання. Нам хотілося звільнити їхній код (далі по тексту називатимемо їхній код «прикладним») від усіляких зав'язок на цільову програмну та апаратну платформу, сховавши всю специфіку в глибинах нашого фреймворку.

Наша компанія активно розвиває свої продукти, тому потрібно було «лагодити поїзд на повному ходу» :)

Потрібно було працювати так, щоб решта розробників не постраждала від нашої діяльності і з комфортом продовжила розробляти свій функціонал під Windows на MSVC. Ця вимога сильно вплинула на багато технічних рішень і дуже ускладнила роботу.

Для того, щоб у читача сформувалося уявлення про масштабність робіт, я наведу деякі цифри:

    Об'єм коду нашого фреймворку

2 мільйони рядків

  • Обсяг «прикладного» коду (код, заснований на платформі НВІС, що вирішує конкретні бізнес-завдання), складно оцінити, але він у рази перевищує обсяг Платформи
  • Понад тисячу програмістів у десяти центрах розробки
  • Сумний вступ закінчено. Тепер наблизимося до справи та розглянемо, з якими проблемами ми зіткнулися.

    Використання API операційної системи

    Як згадувалося вище, стандартна бібліотека С++ дуже мізерна, вона включає багатьох всюди необхідних можливостей. Наприклад, у С++11 немає функціоналу для роботи з мережею… Тобто, як тільки ми захотіли зробити найпростіший HTTP-запит, ми змушені… написати некросплатформний код!

    Ситуація ще більше посилюється, якщо ви використовуєте не найсвіжішу версію компілятора, як це було у нас — у MSVS 2010 огидна підтримка C++11, відсутня величезначастина нововведень у ядрі мови та у стандартній бібліотеці.

    Але, на щастя, такі проблеми вирішуються досить легко. Є кілька способів:

    • Пишемо свій клас, з кількома платформоспецифічними реалізаціями, що базуються на викликах API цільової системи. Під час складання препроцесорними директивами ifdef вибираємо відповідну реалізацію.
    • Використовуємо кросплатформові бібліотеки — є безліч готових кросплатформових бібліотек (знову ж таки, що використовують у собі платформоспецефічні реалізації), які сильно полегшують наше завдання. Наприклад, для реалізації HTTP клієнта ми взяли cURL.

    Особливості реалізації компіляторів

    Кожна програма має помилки. І компілятор також не виняток. Тому навіть на 100% код, що відповідає Стандарту, може не зібратися на якомусь компіляторі.

    Також практично всі розробники компіляторів вважають своїм обов'язком додати у своє дітище можливостей, не передбачених Стандартом, і цим провокують програмістів до написання непереносимого коду.

    Що отримуємо у результаті? Код, який чітко написаний за Стандартом, може не зібратися на якомусь компіляторі; код, який компілюється та працює на одному компіляторі, може не зібратися або заробити не так на іншому…

    Можна перерахувати багато проблем цього класу. Ось одна з них:

    Цей код збереться в MSVC++, тому що у них визначено додатковий конструктор:

    На жаль, немає загальних прийомів, які вирішують такі проблеми. У цих випадках допомагає тільки досвід, накопичений при вивченні інструментів, що використовуються в роботі, і гарне знання Стандарту С++.

    Невизначена поведінка

    Невизначена поведінка (англ. undefined behavior, у низці джерел непередбачуванаповедінка [1] [2]) - властивість деяких мов програмування (найбільш помітно в Сі), програмних бібліотек та апаратного забезпечення в певних маргінальних ситуаціях видавати результат, що залежить від реалізації компілятора (бібліотеки, мікросхеми) і випадкових факторів на зразок стану пам'яті або переривання, що спрацював . Іншими словами, специфікація не визначає поведінку мови (бібліотеки, мікросхеми) у будь-яких можливих ситуаціях, а каже: «за умови А результат операції Б не визначено». Допускати таку ситуацію у програмі вважається помилкою; навіть якщо на деякому компіляторі програма успішно виконується, вона не буде кросплатформною і може відмовити на іншій машині, іншій ОС або при інших налаштуваннях компілятора.

    свій

    Якщо ви допустите undefined behavior у своїй програмі, то це зовсім не означає, що вона падатиме або видаватиме якісь помилки в консоль. Така програма цілком може працювати очікуваним чином. Але будь-яка зміна налаштувань компілятора, перехід на інший компілятор або іншу версію компілятора, або навіть модифікація будь-якого фрагмента коду може змінити поведінку програми і все зламати!

    Багато ситуації з undefined behavior на одному конкретному компіляторі видають стабільно однакову поведінку, і ваш ретельно відтестований додаток буде працювати як швейцарський годинник. Але щойно ми змінюємо оточення (наприклад, намагаємося запустити програму, зібрану іншим компілятором), ці баги починають заявляти себе і повністю ламають програму.

    Класичний приклад undefined behavior – це вихід за межі масиву на стеку. Нижче наведено спрощений фрагмент коду однієї з наших програм із такою проблемою. Цей баг ніяк не проявляв себе під Windows протягом кількох років і «вистрілив» лише післяпортування під Linux:

    Мабуть, MSVS вирівнювала буфер на стеку, додаючи після нього кілька байт, і при перезаписі чужої пам'яті ми потрапляли на порожнє місце, яке ніким не використовується. А в GCC проблема стала проявлятися цікавим чином — програма падала далеко від цього коду, в інші функції (мабуть, GCC заінлайнував цю функцію, і вона почала переписувати локальні змінні іншої функції).

    Є і більш витончені, важко вловимі ситуації з UB. Наприклад, дуже цікаві граблі можна наступити під час використання std::sort:

    Здавалося б, де тут може бути UB? А вся річ у «поганому» компараторі. Компаратор повинен повернути true, якщо s1 потрібно поставити перед s2. Розглянемо, що видасть наш компаратор, якщо йому на вхід подати два порожні рядки:

    s1 = ""; s2 = ""; cmp( s1, s2 ) == true => s1 має стояти перед s2 cmp( s2, s1 ) == true => s2 має стояти перед s1

    І це не вигаданий приклад. Таку проблему ми зловили під час переходу на Linux. Компаратор з подібною помилкою працював довгі роки під Windows і почав рушити додаток з SIGSEGV під Linux (i686). Що цікаво, баг поводиться по-різному навіть на різних дистрибутивах Linux (з різними GCC на борту): десь додаток падає, десь зависає, десь просто сортує не так, як очікувалося.

    Найчастіше ситуації з undefined behavior можна відловити статичними аналізаторами (зокрема і вбудованими у компілятор). Тому в налаштуваннях збірки завжди слід виставляти максимальний рівень попереджень. А щоб не втратити корисний warning в натовпі попереджень виду «не змінна змінна», корисно одного разу прибратися в коді, після чого включити опцію збірки «трактувати попередження як помилки», щоб не допустити появи новихнепомічених попереджень.

    Моделі даних

    Стандарт С++ не дає жодних жорстких гарантій щодо представлення типів даних у пам'яті комп'ютера; він задає лише деякі співвідношення (наприклад, sizeof(char)». У Windows такий код скомпілюється, а в Linux ви отримаєте помилку, що файл з ім'ям myfolder\file.h не знайдений.

    Але, на щастя, уникнути таких проблем дуже просто - достатньо прийняти правила іменування файлів (наприклад, називати всі файли в нижньому регістрі) і дотримуватися їх, а як роздільники шляхів завжди використовувати "/" (Windows його теж підтримує).

    Щоб повністю виключити прикро помилки, ми повісили на свої git-репозиторії простий hook, який перевіряє відповідність include-директив цих правил.

    Також особливості ФС впливають і на сам додаток. Наприклад,

    Якщо у вас є код, який "клеїть" шляхи через звичайні операції конкатенації рядків і використовує "\" як роздільники, то він зламається, тому що під деякими ОС роздільник буде сприйнятий як частина імені файлу.

    Звичайно, можна використовувати '/', але в Windows це виглядає некрасиво, та й у загальному випадку немає гарантій, що не знайдеться ОС, в якій використовуватиметься якийсь інший роздільник.

    Для вирішення цієї проблеми ми використовуємо бібліотеку boost::filesystem. Вона дозволяє правильно сформувати шлях під поточну систему:

    Висновок

    Розробка кроссплатформенного на С++ — це нетривіальне завдання. Написати програму, яка працюватиме на різних програмних та апаратних платформах, не докладаючи для цього жодних додаткових зусиль, мабуть, неможливо. Та й розробити на С++ велику програму, яка без змін правильно збереться на будь-якому компіляторі під будь-яку ОС та під будь-яке залізо,не можна, незважаючи на те, що С++ - кроссплатформенна мова. Але якщо ви дотримуватиметеся низки правил, які я коротко виклав у статті, то ви зможете написати код, який запуститься на всіх потрібних вам платформах. Та й перенести цю програму під нову ОС чи залізо буде вже не так важко.

    Разом, щоб писати кросплатформовий код потрібно:

      Добре знати Стандарт С++, розуміти, що допускається в ньому, а що є розширенням конкретного компілятора або зовсім призводить до undefined behavior.

    Відмовитися від використання API системи в коді, інкапсулюючи платформоспецифічний код в деяких класах або скористатися готовими кросплатформовими бібліотеками.

    Враховувати можливі відмінності типізації, не зав'язуватись на властивості базових типів, які не гарантуються Стандартом С++. Для цього можна використовувати типи з фіксованою розмірністю із стандартної бібліотеки С++.

    Визначитись із форматом представлення рядків у пам'яті програми. Тут може бути багато варіантів. Наприклад, використовувати UTF-8, як це зроблено в багатьох програмах, або зовсім перейти на "широкі" рядки, абстрагувавшись від формату представлення рядків зовсім.

  • Враховувати особливості файлових систем на різних ОС (як у коді, директивах #include, так і в логіці самої програми).
  • Автор: Олексій Коновалов

    Ви можете допомогти і перевести небагато коштів на розвиток сайту