Проект Miranda NG отримує приз «дикі покажчики» (частина перша)

Miranda NG
Я дістався до проекту Miranda NG і перевірив його за допомогою аналізатора коду PVS-Studio. На жаль, з точки зору роботи з пам'яттю і покажчиками це самий неакуратний проект з бачених мною. Хоча я уважно не аналізував результати, помилок настільки багато, що я вирішив розбити зібраний матеріал на 2 статті. Перша стаття буде присвячена вказівниками, а друга всьому іншому. Бажаю приємного читання, і не забудьте взяти попкорн.

Про перевірку Miranda NG
Проект Miranda NG — це наступник популярного мультипротокольной IM-клієнта для Windows, Miranda IM.

Взагалі, спочатку я не збирався перевіряти Miranda NG. Нам просто потрібно кілька активно розвиваються проектів, щоб тестувати нову фічу PVS-Studio. Можна використовувати спеціальну базу, в якій зберігається інформація про повідомлення, які не треба видавати. Докладніше про це розказано тут. Ідея в наступному. Іноді складно впровадити статичний аналіз у великий проект, так як видається занадто багато попереджень і незрозуміло, що з ними робити і кому це доручити. Але хочеться почати отримувати користь від статичного аналізу вже зараз. Тому для початку можна приховати всі попередження і розглядати тільки нові, які з'являються в процесі написання нового коду або рефакторінгу. А вже потім, коли є сили і бажання, можна потихеньку правити помилки в старому коді.

Одним з активно розвиваються проектів виявився Miranda NG. Але коли я подивився, що видав PVS-Studio при першому запуску, я зрозумів, що у мене є матеріал на нову статтю.

Отже, давайте подивимося, що знайшов статичний аналізатор коду PVS-Studio у вихідних кодах Miranda NG.

Для перевірки Miranda NG з репозиторію був узятий Trunk. Хочу зазначити, що я дивився звіт аналізатора досить поверхово і напевно багато втратив. Я пробігся тільки за диагностикам загального призначення 1 і 2-ого рівня. Третій рівень я навіть не став дивитися. Мені і перших двох вистачило з надлишком.

Частина перша. Покажчики та робота з пам'яттю

Разыменовывание нульового покажчика
void CMLan::OnRecvPacket(u_char* mes, int len, in_addr from)
{
....
TContact* cont = m_pRootContact;
....
if (!cont)
RequestStatus(true, cont->m_addr.S_un.S_addr);
....
}

Попередження PVS-Studio: V522 Dereferencing of the null pointer 'cont' might take place. EmLanProto mlan.cpp 342

Тут все просто. Раз покажчик дорівнює NULL, то давайте його разыменуем і подивимося, що веселого з цього вийде.

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

Типовий приклад:
void TSAPI BB_InitDlgButtons(TWindowData *dat)
{
....
HWND hdlg = dat->hwnd;
....
if (dat == 0 || hdlg == 0) { return; }
....
}

Попередження PVS-Studio: V595 The 'dat' pointer was utilized before it was verified against nullptr. Check lines: 428, 430. TabSRMM buttonsbar.cpp 428

Якщо у функцію BB_InitDlgButtons() передати NULL, то перевірка буде зроблена занадто пізно. Аналізатор видав на код проекту Mirannda NG ще 164 таких попередження. Немає сенсу приводити їх у статті. Ось всі вони списком: MirandaNG-595.txt.

Потенційно неініціалізований вказівник
BSTR IEView::getHrefFromAnchor(IHTMLElement *element)
{
....
if (SUCCEEDED(....) {
VARIANT variant;
BSTR url;
if (SUCCEEDED(element->getAttribute(L"href", 2, &variant) &&
variant.vt == VT_BSTR))
{
url = mir_tstrdup(variant.bstrVal);
SysFreeString(variant.bstrVal);
}
pAnchor->Release();
return url;
}
....
}

Попередження PVS-Studio: V614 Potentially uninitialized pointer 'url' used. IEView ieview.cpp 1117

Якщо умова if (SUCCEEDED(....)) не виконується, то змінна 'url' залишається неинициализированной і функція повинна повернути незрозуміло що. Але не все так просто. Код містить ще одну помилку. Неправильно поставлена дужка закривається. В результаті макрос SUCCEEDED застосовується до висловлення типу 'bool', що не має сенсу.

Другий баг компенсує перший. Подивимося, що таке макрос SUCCEEDED:
#define SUCCEEDED(hr) (((HRESULT)(hr)) >= 0)

Вираз типу 'bool' дорівнює 0 або 1. У свою чергу, 0 або 1 завжди >= 0. Виходить, що макрос SUCCEEDED завжди повертає істину і змінна 'url' завжди ініціалізується.

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

Якщо виправити 2 помилки, то код буде виглядати так:
BSTR url = NULL;
if (SUCCEEDED(element->getAttribute(L"href", 2, &variant)) &&
variant.vt == VT_BSTR)

Аналізатор підозрює недобре ще в 20 місцях. Ось вони: MirandaNG-614.txt.

Плутанина між розміром масиву і кількістю елементів
Кількість елементів у масиві та розмір масиву (в байтах — дві різні сутності. Однак, якщо бути недостатньо уважним, їх цілком можна переплутати. Проект Mirannda NG демонструє безліч способів, як може виглядати така плутанина.

Найбільше шкоди завдав макрос SIZEOF:
#define SIZEOF(X) (sizeof(X)/sizeof(X[0]))

Цей макрос обчислює кількість елементів у масиві. Але, мабуть, хтось із програмістів вважає, що це аналог оператора sizeof(). Правда, не зрозуміло, навіщо він тоді використовує макрос, а не стандартний sizeof(). Так що є і інший варіант — хтось не знає, як використовувати функцію memcpy().

Типовий випадок:
int CheckForDuplicate(MCONTACT contact_list[], MCONTACT lparam)
{
MCONTACT s_list[255] = { 0 };
memcpy(s_list, contact_list, SIZEOF(s_list));
for (int i = 0;i++) {
if (s_list[i] == lparam)
return i;
if (s_list[i] == 0)
return -1;
}
return 0;
}

Попередження PVS-Studio: V512 A call of the 'memcpy' function will lead to underflow of the buffer 's_list'. Sessions utils.cpp 288

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

Точно так само неправильно макрос SIZEOF() використовується ще в 8 місцях: MirandaNG-512-1.txt.

Наступна біда. Часто програмісти забувають поправити виклики memset()/memcpy() коли впроваджують програму Unicode:
void checkthread(void*)
{
....
WCHAR msgFrom[512];
WCHAR msgSubject[512];
ZeroMemory(msgFrom,512);
ZeroMemory(msgSubject,512);
....
}

Попередження PVS-Studio:
  • V512 A call of the 'memset' function will lead to underflow of the buffer 'msgFrom'. LotusNotify lotusnotify.cpp 760
  • V512 A call of the 'memset' function will lead to underflow of the buffer 'msgSubject'. LotusNotify lotusnotify.cpp 761
Функція ZeroMemoty() очистить тільки половину буфера, так як символи типу WCHAR займають 2 байти.

А ось приклад часткового копіювання рядка:
INT_PTR CALLBACK DlgProcMessage(....)
{
....
CopyMemory(tr.lpstrText, _T("mailto:"), 7);
....
}

Попередження PVS-Studio: V512 A call of the 'memcpy' function will lead to underflow of the buffer 'L«mailto:»'. TabSRMM msgdialog.cpp 2085

Буде скопійована тільки частину рядка. Кожний символ рядка займає 2 байти. Значить треба скопіювати 14 байт, а не 7.

Аналогічно:
  • userdetails.cpp 206
  • weather_conv.cpp 476
  • dirent.c 138
Наступна помилка допущена просто через неуважність:
#define MSGDLGFONTCOUNT 22

LOGFONTA logfonts[MSGDLGFONTCOUNT + 2];

void TSAPI CacheLogFonts()
{
int i;
HDC hdc = GetDC(NULL);
logPixelSY = GetDeviceCaps(hdc, LOGPIXELSY);
ReleaseDC(NULL, hdc);

ZeroMemory(logfonts, sizeof(LOGFONTA) * MSGDLGFONTCOUNT + 2);
....
}

Попередження PVS_Studio: V512 A call of the 'memset' function will lead to underflow of the buffer 'logfonts'. TabSRMM msglog.cpp 134

Хтось поспішив і змішав до купи розмір об'єкта і їх кількість. Додавати двійку потрібно до множення. Коректний код:
ZeroMemory(logfonts, sizeof(LOGFONTA) * (MSGDLGFONTCOUNT + 2));

У наступному прикладі хотіли, як краще, використовували sizeof(), але знову заплуталися з розмірами. Отримали значення більше, ніж потрібно.
BOOL HandleLinkClick(....)
{
....
MoveMemory(tr.lpstrText + sizeof(TCHAR)* 7,
tr.lpstrText,
sizeof(TCHAR)*(tr.chrg.cpMax - tr.chrg.cpMin + 1));
....
}

Попередження PVS-Studio: V620 it's unusual that the expression of sizeof(T)*N kind is being summed with the pointer to T type. Scriver input.cpp 387

Змінна 'tr.lpstrText' вказує на рядок, що складається з символів типу wchat_t. Якщо хочеться пропустити 7 символів, то потрібно просто додати 7. Не потрібно множити на sizeof(wchar_t).

Така ж помилка тут: ctrl_edit.cpp 351

На жаль, це ще не кінець. Ще можна помилитися так:
INT_PTR CALLBACK DlgProcThemeOptions(....)
{
....
str = (TCHAR *)malloc(MAX_PATH+1);
....
}

Попередження PVS-Studio: V641 The size of the allocated memory buffer is not a multiple of the element size. KeyboardNotify options.cpp 718

Забули помножити на sizeof(TCHAR). В цьому ж файлі можна побачити ще 2 помилки в рядках 819 і 1076.

І останній фрагмент коду на тему кількості елементів:
void createProcessList(void)
{
....
ProcessList.szFileName[i] =
(TCHAR *)malloc(wcslen(dbv.ptszVal) + 1);

if (ProcessList.szFileName[i])
wcscpy(ProcessList.szFileName[i], dbv.ptszVal);
....
}

Попередження PVS-Studio: V635 Consider inspecting the expression. The length should probably be multiplied by the sizeof(wchar_t). KeyboardNotify main.cpp 543

Ще варто дописати множення на sizeof(TCHAR) тут: options.cpp 1177, options.cpp 1204.

З розмірами розібралися, перейдемо до інших способів вистрілити собі в ногу покажчиком.

Вихід за кордон масиву
INT_PTR CALLBACK DlgProcFiles(....)
{
....
char fn[6], tmp[MAX_PATH];
....
SetDlgItemTextA(hwnd, IDC_WWW_TIMER,
_itoa(db_get_w NULL, MODNAME, strcat(fn, "_timer"), 60),
tmp, 10));
....
}

V512 A call of the 'strcat' function will lead to overflow of the buffer 'fn'. NimContact files.cpp 290

Рядок "_timer" не влазить в масив 'fn'. Хоча у рядку всього 6 символів, потрібно не забувати про термінальний 0. Теоретично тут має місце невизначений поведінку. На практиці вийде, що буде зачеплений масив 'tmp'. В нульовий елемент масиву 'tmp' буде записаний '0'.

Наступний приклад ще сумніші. Тут зіпсується HANDLE якийсь іконки:
typedef struct
{
int cbSize;
char caps[0x10];
HANDLE hIcon;
char name[MAX_CAPNAME];
} ICQ_CUSTOMCAP;

void InitCheck()
{
....
strcpy(cap.caps, "GPG AutoExchange");
....
}

Попередження PVS-Studio: V512 A call of the 'strcpy' function will lead to overflow of the buffer 'cap.caps'. New_GPG main.cpp 2246

Знову не врахований термінальний 0. Можливо, тут доречніше було б скористатися функцією memcpy().

Інші місця з проблемним кодом:
  • main.cpp 2261
  • messages.cpp 541
  • messages.cpp 849
  • utilities.cpp 547

Велика і жахлива функція strncat()
Багато чули про небезпеку функції strcat() і використовую на їх погляд більш безпечну strncat(). От тільки рідко хто вміє поводитися з цією функцією правильно. Вона набагато небезпечніше, ніж може здатися. Справа в тому, що третій аргумент не задає максимальну довжину буфера, а скільки в буфері залишилося вільного місця.

Ось такий код абсолютно некоректний:
BOOL ExportSettings(....)
{
....
char header[512], buff[1024], abuff[1024];
....
strncat(buff, abuff, SIZEOF(buff));
....
}

Попередження PVS-Studio: V645 The 'strncat' function call could lead to the 'buff' buffer overflow. The bounds should not contain the size of the buffer, but a number of characters it can hold. Miranda fontoptions.cpp 162

Якщо, 'buff' зайнятий на половину, то такий код не зверне на це увагу і дозволить додати ще 1000 символів. І таким чином відбудеться вихід за кордон масиву. Причому дуже істотний. З таким же успіхом можна було просто писати strcat().

Взагалі, запис strncat(...., ...., SIZEOF(X)) в принципі неправильна. Вона означає, що в масиві ЗАВЖДИ є ще вільне місце.

В Miranda NG є ще 48 місць де так неправильно використовується strncat(). Ось вони: MirandaNG-645-1.txt.

До речі, ці місця в коді можна розглядати як потенційні уразливості.

У виправдання програмістів з Miranda NG, варто відзначити, що деякі все таки читали опис функції strncat(). Вони пишуть так:
void __cdecl GGPROTO::dccmainthread(void*)
{
....
strncat(filename, (char*)local_dcc->file_info.filename,
sizeof(filename) - strlen(filename));
....
}

Попередження PVS-Studio: V645 The 'strncat' function call could lead to the 'filename' buffer overflow. The bounds should not contain the size of the buffer, but a number of characters it can hold. GG filetransfer.cpp 273

На жаль, це знову неправильно. Принаймні так можна зіпсувати 1 байт за межами масиву. І я думаю, ви вже здогадалися, що причина в злощасному термінальному нулі. Він не врахований.

Пояснимо цю помилку на простому прикладі:
char buf[5] = "ABCD";
strncat(buf, "Е", 5 - strlen(buf));

У буфері вже немає місця для нових символів. В ньому знаходиться 4 символу і термінальний нуль. Вираз «5 — strlen(buf)» дорівнює 1. Функція strncpy() копіює символ «E» в останній елемент масиву 'buf'. Термінальний 0 буде записаний вже за межами буфера.

Інші 34 місця коду зібрані тут: MirandaNG-645-2.txt.

Еротика з використанням new[] і delete
Хтось систематично забуває писати квадратні дужки для оператора delete:
extern "С" int __declspec(dllexport) Load(void)
{
int wdsize = GetCurrentDirectory(0, NULL);
TCHAR *workingDir = new TCHAR[wdsize];
GetCurrentDirectory(wdsize, workingDir);
Utils::convertPath(workingDir);
workingDirUtf8 = mir_utf8encodeT(workingDir);
delete workingDir;
....
}

Попередження PVS-Studio: V611 The memory was allocated using 'new T[]' operator but was released using the 'delete' operator. Consider inspecting this code. It's probably better to use 'delete [] workingDir;'. IEView ieview_main.cpp 68

Ось тут 20 таких місць: MirandaNG-611-1.txt.

Втім, такі помилки часто обходяться без видимих надалі. Саме тому я відніс їх до розділу «еротика». Більш жорстке видовище чекає нижче.

Розпусту з використанням new, malloc, delete і free
Плутаються способи виділення та звільнення пам'яті:
void CLCDLabel::UpdateCutOffIndex()
{
....
int *piWidths = new int[(*--m_vLines.end()).length()];
....
free(piWidths);
....
}

Попередження PVS-Studio: V611 The memory was allocated using 'new' operator but was released using the 'free' function. Consider inspecting operation logics behind the 'piWidths' variable. MirandaG15 clcdlabel.cpp 209

Ще 11 поз з Камасутри тут: MirandaNG-611-2.txt.

Безглузді перевірки
У разі нестачі пам'яті звичайний оператор 'new' генерує виняток. Тому немає сенсу перевіряти покажчик, який повернув оператор 'new' на рівність нулю.

Зазвичай зайва перевірка нешкідлива. Однак, іноді зустрічається ось такий код:
int CIcqProto::GetAvatarData(....)
{
....
ar = new avatars_request(ART_GET); // get avatar
if (!ar) { // out of memory, go away
m_avatarsMutex->Leave();
return 0;
}
....
}

Попередження PVS-Studio: V668 There is no sense testing in the 'ar' pointer against null, as the memory was allocated using the 'new' operator. The exception will be generated in the case of memory allocation error. ICQ icq_avatar.cpp 608

У випадку помилки слід звільнити м'ютекс. Але цього не станеться. У випадку помилки створення об'єкта події будуть розвиватися зовсім не так, як планує програміст.

Пропоную розробникам перевірити наступні 83 аналогічних попереджень аналізатора: MirandaNG-668.txt.

Плутанина між SIZEOF() і _tcslen()
#define SIZEOF(X) (sizeof(X)/sizeof(X[0]))
....
TCHAR *ptszVal;
....
int OnButtonPressed(WPARAM wParam, LPARAM lParam)
{
....
int FinalLen = slen + SIZEOF(dbv.ptszVal) + 1;
....
}

Попередження PVS-Studio: V514 Dividing sizeof a pointer 'sizeof (dbv.ptszVal)' by another value. There is a probability of logical error presence. TranslitSwitcher layoutproc.cpp 827

Тут написано що-то дивне. Макрос SIZEOF() застосовується до покажчика, що не має ніякого сенсу. Є підозри, що хотіли підрахувати довжину рядка. Тоді для цього слід використовувати функцію _tcslen().

Аналогічно:
  • layoutproc.cpp 876
  • layoutproc.cpp 924
  • main.cpp 1300

Псування vptr
class CBaseCtrl
{
....
virtual void Release() { }
віртуальний BOOL OnInfoChanged(MCONTACT hContact, LPCSTR pszProto);
....
};

CBaseCtrl::CBaseCtrl()
{
ZeroMemory(this, sizeof(*this));
_cbSize = sizeof(CBaseCtrl);
}

Попередження PVS-Studio: V598 The 'memset' function is used to nullify the fields of 'CBaseCtrl' class. Virtual method table will be damaged by this. UInfoEx ctrl_base.cpp 77

Програміст полінувався і для обнулення полів класу скористався функцією ZeroMemory(). Він не врахував, що цей клас містить покажчик на таблицю віртуальних методів. У базовому класі багато методи оголошені як віртуальні. Псування вказівник на таблицю віртуальних методів призведе до невизначеного поведінки при роботі з об'єктом, инициализированным таким кустарним методом.

Аналогічно:
  • ctrl_base.cpp 87
  • ctrl_base.cpp 103.

Час життя об'єктів
static INT_PTR CALLBACK DlgProcFindAdd(....)
{
....
case IDC_ADD:
{
ADDCONTACTSTRUCT acs = {0};

if (ListView_GetSelectedCount(hwndList) == 1) {
....
}
else {
.... 
PROTOSEARCHRESULT psr = { 0 }; <<<---
psr.cbSize = sizeof(psr);
psr.flags = PSR_TCHAR;
psr.id = str;

acs.psr = &psr; <<<---
acs.szProto = (char*)SendDlgItemMessage(....);
}
acs.handleType = HANDLE_SEARCHRESULT;
CallService(MS_ADDCONTACT_SHOW,
(WPARAM)hwndDlg, (LPARAM)&acs);
}
break;
....
}

Попередження PVS-Studio: V506 Pointer to local variable 'psr' is stored outside the scope of this variable. Such a pointer will become invalid. Miranda findadd.cpp 777

Об'єкт 'psr' перестане існувати, коли відбудеться вихід із else-гілки. Однак, вказівник на цей об'єкт був збережений і буде в подальшому використовуватися. Приклад цього «дикого покажчика». До чого призведе робота з ним — невідомо.

Ще один аналогічний приклад:
HMENU BuildRecursiveMenu(....)
{
....
if (GetKeyState(VK_CONTROL) & 0x8000) {
TCHAR str[256];
mir_sntprintf(str, SIZEOF(str),
_T("%s (%d, id %x)"), mi->pszName,
mi->position, mii.dwItemData);

mii.dwTypeData = str;
}
....
}

Попередження PVS-Studio: V507 Pointer to local array 'str' is stored outside the scope of this array. Such a pointer will become invalid. Miranda genmenu.cpp 973

Текст роздруковується під тимчасовий масив, який тут же знищується. Однак, вказівник на цей масив буде використаний десь в іншій частині програми.

Дивно, як такі програми взагалі працюють. Ще 9 місць, де мешкають дикі покажчики: MirandaNG-506-507.txt.

Муки 64-бітних покажчиків
Я не вивчав 64-бітні діагностики. Подивився тільки попередження з номером V220. Майже кожне таке попередження — справжня помилка.

Приклад некоректного коду з точки зору 64-бітності:
typedef LONG_PTR LPARAM;

LRESULT
WINAPI
SendMessageA(
__in HWND hWnd,
__in UINT Msg,
__in WPARAM wParam,
__in LPARAM lParam);

static INT_PTR CALLBACK DlgProcOpts(....)
{
....
SendMessageA(hwndCombo, CB_ADDSTRING, 0, (LONG)acc[i].name);
....
}

Попередження PVS-Studio: V220 Suspicious sequence types of castings: memsize -> 32-bit integer -> memsize. The value being casted: 'acc[i].name'. GmailNotifier options.cpp 55

Куди-то треба передати 64-бітний покажчик. Для цього, його потрібно перетворити в тип LPARAM. Однак, замість цього покажчик насильно перетворюють в 32-бітний тип LONG. І тільки потім він буде автоматично подовжено до LONG_PTR. Ця помилка прийшла з 32-бітних часів, коли типи LONG і LPARAM збігалися. Тепер це не так. Будуть зіпсовані старші 32 біта в 64-бітному покажчику.

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

Ось ще 20 місць, де псуються 64-розрядні покажчики: MirandaNG-220.txt.

Неочищені приватні дані
void CAST256::Base::UncheckedSetKey(....)
{
AssertValidKeyLength(keylength);
word32 kappa[8];
....
memset(kappa, 0, sizeof(kappa));
}

Попередження PVS-Studio: V597 The compiler could delete the 'memset' function call, which is used to flush 'kappa' buffer. The RtlSecureZeroMemory() function should be used to erase the private data. Cryptlib cast.cpp 293

У версії Release компілятор видалить виклик функції memset(). Чому так, можна дізнатися з описи діагностики.

Не затрутся приватні дані ще в 6 місцях: MirandaNG-597.txt.

Різне
Є ще пара попереджень аналізатора, які я піду в одну купу.
void LoadStationData(...., WIDATA *Data)
{
....
ZeroMemory(Data, sizeof(Data));
....
}

Попередження PVS-Studio: V512 A call of the 'memset' function will lead to underflow of the buffer 'Data'. Weather weather_ini.cpp 250

Вираз 'sizeof(Data)' повертає розмір покажчика, а не WIDATA. Буде обнулено лише частина об'єкта. Правильно буде написати: sizeof(*Data).
void CSametimeProto::CancelFileTransfer(HANDLE hFt)
{
....
FileTransferClientData* ftcd = ....;

if (ftcd) {
while (mwFileTransfer_isDone(ftcd->ft) && ftcd)
ftcd = ftcd->next;
....
}

Попередження PVS-Studio: V713 The pointer ftcd was utilized in the logical expression before it was verified against nullptr in the same logical expression. Sametime files.cpp 423

В умові циклу покажчик 'ftcd' на початку разыменовывается, а тільки потім перевіряється. Мабуть, вираз варто переписати так:
while (ftcd && mwFileTransfer_isDone(ftcd->ft))

Висновок
Робота з вказівниками і пам'яттю — не єдине, з чого складаються програми на Сі++. У наступній статті ми розглянемо інші різновиди помилок, виявлених у Miranda NG. Їх менше, але все одно досить багато.

Ця стаття англійською
Якщо хочете поділитися цією статтею з англомовної аудиторією, то прошу використовувати посилання на переклад: Andrey Karpov. Miranda NG Project to Get the Wild Pointers» Award (Part 1).

Прочитали статтю і є питання?Часто до наших статей задають одні і ті ж питання. Відповіді на них ми зібрали тут: Відповіді на питання читачів статей про PVS-Studio і CppCat, версія 2014. Будь ласка, ознайомтеся зі списком.


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.