Як зробити свій код кроссплатформенным
Можливо, хтось, прочитавши заголовок, запитає: «Навіщо робити щось зі своїм кодом?! Адже С++ кросплатформна мова!». Загалом це так… але тільки поки ніде немає зав'язок на специфічні можливості компілятора та цільової платформи…
У реальному житті розробники, які вирішують конкретне завдання під конкретну платформу, рідко запитують: «Чи точно це відповідає Стандарту С++? А раптом це розширення мого компілятора». Вони пишуть код, запускають складання і лагодять місця, на які вилаявся їх компілятор.
У результаті отримуємо додаток, який, до певної міри, «заточений» під конкретний компілятор (і навіть під його конкретну версію!) і цільову ОС. Більш того, через мізерність стандартної бібліотеки С++ деякі речі просто неможливо написати, не скориставшись специфічним API системи.
Так було й у нас у Тензорі. Ми писали на MS Visual Studio 2010. Наші продукти були 32-бітними Windows-додатками. І, зрозуміло, код був пронизаний всілякими зав'язками на технології від Microsoft. Одного разу ми вирішили, що настав час освоювати нові горизонти: настав час навчити НВІС працювати під Linux та іншими ОС, настав час спробувати перейти на інше «залізо» (POWER).
У цьому циклі статей я розповім, як ми зробили свої продукти справжніми кроссплатформенными додатками; як змусили їх працювати на Linux, MacOS і навіть під iOS та Android; як запустили свої програми на безлічі апаратних архітектур (x86-64, POWER, ARM та інші); як навчили працювати на big-endian машинах

На базі Платформи наші розробники реалізують свої продукти (навіть мобільні додатки), які вирішують різноманітні бізнес-завдання. Нам хотілося звільнити їхній код (далі по тексту називатимемо їхній код «прикладним») від усіляких зав'язок на цільову програмну та апаратну платформу, сховавши всю специфіку в глибинах нашого фреймворку.
Наша компанія активно розвиває свої продукти, тому потрібно було «лагодити поїзд на повному ходу» :)
Потрібно було працювати так, щоб решта розробників не постраждала від нашої діяльності і з комфортом продовжила розробляти свій функціонал під 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, як це зроблено в багатьох програмах, або зовсім перейти на "широкі" рядки, абстрагувавшись від формату представлення рядків зовсім.
Ви можете допомогти і перевести небагато коштів на розвиток сайту