П'ять популярних міфів про C++, частина 1

1. Введення

У цій статті я спробую дослідити і розвінчати п'ять популярних міфів про C++:

1. Щоб зрозуміти З++, спочатку потрібно вивчити З
2. С++ — це об'єктно-орієнтована мова програмування
3. В надійних програмах необхідна збірка сміття
4. Для досягнення ефективності необхідно писати низькорівневий код
5. З++ підходить тільки для великих і складних програм

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

Мені вони здаються популярними, тому що я їх часто чую. Іноді їх аргументовано доводять, але частіше використовують як аксіоми. Часто їх використовують, щоб відмести С++ як один з можливих варіантів вирішення якої-небудь задачі.

Кожному міфу можна присвятити книгу, але я обмежусь констатацією і коротким викладом своїх аргументів проти них.

2. Міф 1: Щоб зрозуміти З++, спочатку потрібно вивчити З

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

string compose(const string& name, const string& domain) 
{ 
return name+'@'+domain; 
}


Використовується він так:

string addr = compose("gre","research.att.com"); 


Природно, в реальній програмі не всі аргументи будуть рядками.

В З-версії необхідно безпосередньо працювати з символами і пам'яттю:

char* compose(const char* name, const char* domain) 
{ 
char* res = malloc(strlen(name)+strlen(domain)+2); // місце для рядків, '@', 0 
char* p = strcpy(res,name); 
p += strlen(name); 
*p = '@'; 
strcpy(p+1,domain); 
return res; 
} 


Використовується він так:

char* addr = compose("gre","research.att.com"); 
// ... 
free(addr); // по закінченню звільнити пам'ять 


Який варіант легше викладати? Який легше використовувати? Не наплутав чи я чого З-версії? Точно? Чому?

І, нарешті, яка з версій compose() більш ефективна? С++ — тому що їй не треба підраховувати символи в аргументах і вона не використовує динамічну пам'ять для коротких рядків.

2.1 Вивчення С++
Це не який-небудь дивний екзотичний приклад. По-моєму, він типовий. Так чому безліч викладачів проповідують підхід «Спочатку»? Тому, що:

— вони так завжди робили
— того вимагає навчальна програма
— що вони самі вчилися
— раз менше З++, значить, він повинен бути простішим
— студентам все одно, рано чи пізно, доведеться вивчити З

Але — не найпростіше або корисне підмножина С++. Знаючи досить З++, вам буде легко вивчити С. Вивчаючи З перед С++ ви зіткнетеся з безліччю помилок, яких можна легко уникнути на З++, і ви будете витрачати час на вивчення того, як їх уникнути. Для правильного підходу до вивчення С++ подивіться мою книгу Programming: Principles and Practice Using C++. В кінці є навіть глава про те, як використовувати С. Вона з успіхом застосовувалася в навчанні безлічі студентів. Для спрощення вивчення її друге видання використовує З++11 і С++14.

Завдяки С++11, С++ став більш дружнім для новачків. Приміром, ось вектор із стандартної бібліотеки, ініційовану послідовністю елементів:

vector<int> v = {1,2,3,5,8,13}; 


В C++98 ми могли ініціалізувати списками тільки масиви. В С++11 ми можемо задати конструктор, приймає список {} для будь-якого типу. Ми можемо пройти по вектору циклом:

For (int x : v) test(x); 


test() буде викликана для кожного елемента v.

Цикл for може проходити по будь-якій послідовності, тому ми могли б просто написати:

for (int x : {1,2,3,5,8,13}) test(x); 


В С++11 намагалися зробити прості речі простими. Природно, без шкоди швидкодії.

3. Міф 2: С++ — це об'єктно-орієнтована мова програмування

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

Цей міф приводить людей до висновку, що С++ їм не потрібен (порівняно з З), якщо тільки їм не потрібні великі ієрархії класів зі всякими віртуальними функціями. Упевнившись у міфі, С++ докоряють за те, що він не чисто об'єктно-орієнтований. Якщо ви прирівнює «хороший» до «ООП», тоді С++, що містить багато всього, що не відноситься до ООП, автоматично стає «поганим». У будь-якому випадку, цей міф є відмовкою, щоб не вчити С++.

Приклад:

void rotate_and_draw(vector<Shape*>& vs, int r) 
{ 
for_each(vs.begin(),vs.end(), [](Shape* p) { p->rotate®; }); // повернути всі елементи vs 
for (Shape* p : vs) p->draw(); // намалювати всі елементи vs 
} 


Це ООП? Звичайно — тут є ієрархія класів і віртуальні функції. Це узагальнене програмування? Звичайно, тут є параметризований контейнер (вектор) і звичайна функція.

for_each. Це функціональне програмування? Щось на зразок того. Використовується лямбда (конструкція []). І що ж це за стиль? Це сучасний стиль З++11.

Я використовував і стандартний цикл for, і бібліотечний алгоритм for_each, просто для демонстрації можливостей. У цьому коді я б використовував тільки один цикл, будь-який з них.

3.1 Узагальнене програмування.
Хочете більш узагальненого коду? Зрештою, він працює тільки з векторами покажчиків на Shapes. Як щодо списків і вбудованих масивів? Що щодо «розумних покажчиків», типу shared_ptr і unique_ptr? А об'єкти, які називаються не Shape, але які можна draw() і rotate()? Прислухайтеся:

template < typename Iter> 
void rotate_and_draw(Iter first, Iter last, int r) 
{ 
for_each(first,last,[](auto p) { p->rotate®; }); // повернути всі елементи [first:last) 
for (auto p = first; p!=last; ++p) p->draw(); // намалювати всі елементи [first:last) 
}


Це працює з будь-якою послідовністю. Це стиль алгоритмів стандартних бібліотек. Я використовував auto, щоб не називати типу інтерфейсу об'єктів. Це можливість З++11, означає «використовувати тип виразу, який був використаний при ініціалізації», тому для p тип буде той же, що і у first.

Ще приклад:

void user(list<unique_ptr<Shape>>& lus, Container<Blob>& vb) 
{ 
rotate_and_draw(lus.begin(),lus.end()); 
rotate_and_draw(begin(vb),end(vb)); 
}


Тут Blob — якийсь графічний тип, що має операції draw() і rotate(), а Container — тип якогось контейнера. У списку із стандартної бібліотеки (std::list) є методи begin() і end(), які допомагають проходити по послідовності. Це гарне класичне ООП. Але що, якщо Container не підтримує стандартну запис ітерацій з напіввідкритим послідовності, [b:e)? Якщо відсутні методи begin() і end()? Ну, я ніколи не зустрічав чогось на кшталт контейнера, за яким не можна проходити, тому ми можемо визначити окремі begin() і end(). Стандартна бібліотека надає таку можливість для масивів З-стилю, тому якщо Container — масив С, проблема вирішена.

3.2 Адаптація
Випадок складніше: що, якщо Container містить покажчики на об'єкти, і у нього інша модель для доступу і проходу? Приміром, до нього треба звертатися так:

for (auto p = c.first(); p!=nullptr; p=c.next()) { /* зробити що-небудь з *p */} 


Такий стиль не рідкісний. Його можна привести до виду послідовності [b:e) ось так:

template < typename T> struct Iter { 
T* current; 
Container<T>& c; 
}; 
template < typename T> Iter<T> begin(Container<T>& c) { return Iter<T>{c.first(),c}; } 
template < typename T> Iter<T> end(Container<T>& c) { return Iter<T>{nullptr,c}; } 
template < typename T> Iter<T> operator++(Iter<T> p) { p.current = p.c.next(); return *this; } 
template < typename T> T* operator*(Iter<T> p) { return p.current; } 


Така модифікація неагресивна: мені не довелося змінювати Container або ієрархію його класів, щоб привести його до моделі проходу, підтримуваної стандартної бібліотеки С++. Це адаптація, а не рефакторинг. Я вибрав цей приклад для демонстрації того, що такі техніки узагальнене програмування не обмежена стандартною бібліотекою. Крім того, вони не потрапляють під визначення «ОО».

Уявлення, що код З++ зобов'язаний бути ОО (скрізь використовувати ієрархії і віртуальні функції), згубно позначається на швидкодії програм. Якщо вам потрібно аналізувати набір типів під час виконання, це хороший підхід, і я його часто використовую. Однак, він досить негнучкий (не всі типи вміщаються в ієрархію), і виклик віртуальної функції перешкоджає инлайнингу, що може раз в 50 уповільнити вашу програму

4. Міф 3: В надійних програмах необхідна збірка сміття

Збірка сміття добре, але не ідеально, справляється з поверненням невикористовуваної пам'яті. Це не панацея. Пам'ять може зберігатися не безпосередньо, а безліч ресурсів не є тільки лише пам'яттю. Приклад:

class Filter { // прийняти введення з файлу iname і вивести результат у файл oname 
public: 
Filter(const string& iname, const string& oname); // конструктор 
~Filter(); // деструктор 
// ... 
private: 
ifstream is; 
ofstream os; 
// ... 
}; 


Конструктор Filter відкриває два файлу. Після цього виконується певна завдання, приймається введення з файлу і виводиться результат в інший файл. Можна захардкодить завдання Filter і використовувати його як лямбду, чи його можна використовувати як функцію, яку надає наслідуваний клас, перегружающий віртуальну функцію. Для управління ресурсами це неважливо. Можна визначити Filter так:

void user() 
{ 
Filter flt {"books","authors"}; 
Filter* p = new Filter{"novels","favorites"}; 
// використовувати flt і *p 
delete p; 
} 


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

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

Загальноприйнятий і рекомендований підхід в С++ — покладатися на деструктори, щоб упевнитися, що ресурси повернуті. Зазвичай ресурси забирають в конструкторах, що дає цій техніці ім'я «Отримання ресурсів — це ініціалізація». В user() деструктор flt неявно викликає деструктори потоків is і os. Вони, в свою чергу, закривають файли і випускають ресурси, пов'язані з потоками. delete зробив би те ж саме для *p

Досвідчені користувачі сучасного С++ помітять, що user() незграбний і схильний до помилок. Так було б краще:

void user2() 
{ 
Filter flt {"books","authors"}; 
unique_ptr<Filter> p {new Filter{"novels","favorites"}}; 
// використовуємо flt і *p 
}


Тепер по виходу з user() *p автоматично звільняється. Програміст не забуде цього зробити. unique_ptr — клас стандартної бібліотеки, який засвідчується, що ресурси звільнені, без втрати продуктивності і пам'яті, порівняно з вбудованими покажчиками.

Хоча і це рішення занадто багатослівно (Filter повторюється) і поділ конструктора звичайного покажчика (new) і розумного (unique_ptr) вимагає оптимізації. Можна поліпшити це через допоміжну функцію З++14 make_unique, яка створює об'єкт заданого типу і повертає вказує на нього unique_ptr:

void user3() 
{ 
Filter flt {"books","authors"}; 
auto p = make_unique<Filter>("novels","favorites"); 
// використовуємо flt і *p 
} 


Або ще кращий варіант, оскільки нам не особливо потрібен другий Filter:

void user4() 
{ 
Filter flt {"books","authors"}; 
Filter flt2 {"novels","favorites"}; 
// використовуємо flt і flt2 
} 


Коротше, простіше, зрозуміліше, і швидше.

Але що робить деструктор Filter? Звільняє ресурси Filter — закриває файли (викликаючи їх деструктори). Це робиться неявно, тому якщо від Filter більше нічого не потрібно, можна позбутися згадки його деструктора і дати компілятору зробити все самому. Тому, всього-на-всього потрібно написати:

class Filter { // прийняти введення з файлу iname і вивести результат у файл oname 
public: 
Filter(const string& iname, const string& oname); 
// ... 
private: 
ifstream is; 
ofstream os; 
// ... 
}; 

void user3() 
{ 
Filter flt {"books","authors"}; 
Filter flt2 {"novels","favorites"}; 
// використовуємо flt і flt2 
} 


Ця запис простіше більшості записів з мов з автоматичним складанням сміття (Java, C#), і в ній немає витоків з-за забудькуватості. Вона також швидше очевидних альтернатив.

Це — мій ідеал управління ресурсами. Він управляє не тільки пам'яттю, але і іншими ресурсами — файли, потоки, залочки. Але чи насправді він всеосяжний? Щодо об'єктів, у яких немає одного очевидного власника?

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

X* make_X() 
{ 
X* p = new X: 
// ... заповнити X ... 
return p; 
} 
void user() 
{ 
X* q = make_X(); 
/ / ... *q ... 
delete q; 
} 


І хто відповідальний за вилучення об'єкта? У нашому простому випадку — той, хто викликає make_X(), але в загальному відповідь не так очевидний. Що, якщо make_X() кешує об'єкти для мінімізації використання пам'яті? Якщо user() передав покажчик на other_user()? Багато де можна заплутатися і при такому стилі програмування витоку нерідкі. Можна було б скористатися shared_ptr або unique_ptr для безпосереднього визначення власника об'єкта:

unique_ptr<X> make_X(); 


Але навіщо взагалі використовувати вказівник? Часто він не потрібен, він часто відволікає від звичайного використання об'єкта. Наприклад, функція додавання Matrix створює новий об'єкт, суму, з двох аргументів, але повернення вказівника привів би до дивного кодом:

unique_ptr<Matrix> operator+(const Matrix& a, const Matrix& b); 
Matrix res = *(a+b); 


* потрібна для отримання суми, а не покажчика. Що мені реально потрібно — об'єкт, а не покажчик на нього. Дрібні об'єкти швидко копіюються і я не став би використовувати покажчик:

double sqrt(double); // функція квадратного кореня 
double s2 = sqrt(2); // отримати квадратний корінь з двох


З іншого боку, об'єкти, що містять купу даних, зазвичай є обробниками цих даних. istream, string, vector, list і thread — це кілька слів даних для доступу до великих даними. Повернемося до складання Matrix. Що нам потрібно:

Matrix operator+(const Matrix& a, const Matrix& b); // повернути суму a і b
Matrix r = x+y; 


Легко:

Matrix operator+(const Matrix& a, const Matrix& b) 
{ 
Matrix res; 
// ... заповнює res сумами ... 
return res; 
}


За замовчуванням, відбувається копіювання елементів res r, але так як res буде вилучений і його пам'ять звільняється, їх копіювати не потрібно: можна «вкрасти» елементи. Це можна було зробити з перших днів С++, але це було складно реалізувати і техніку розумів не кожен. З++11 підтримує «крадіжка подання» безпосередньо, у вигляді операцій move, передавальних володіння об'єктом. Розглянемо просту двовимірну матрицю з елементів типу double:

class Matrix { 
double* elem; // покажчик на елементи 
int nrow; // кількість рядків
int ncol; // кількість стовпців
public: 
Matrix(int nr, int nc) // конструктор: розмістити елементи
:elem{new double[nr*nc]}, nrow{nr}, ncol{nc} 
{ 
for(int i=0; i<nr*nc; ++i) elem[i]=0; // ініціалізація
} 
Matrix(const Matrix&); // скопіювати конструктор
Matrix operator=(const Matrix&); // скопіювати призначення
Matrix(Matrix&&); // перемістити конструктор
Matrix operator=(Matrix&&); // перемістити призначення
~Matrix() { delete[] elem; } // деструктор: звільнити елементи
// ... 
}; 


Операція копіювання розпізнається за &. Операція переміщення -&&. Операція переміщення повинна «вкрасти» уявлення і залишити позаду «порожній об'єкт». Для Matrix це означає щось на кшталт:

Matrix::Matrix(Matrix&& a) // перемістити конструктор
:nrow{a.nrow}, ncol{a.ncol}, elem{a.elem} // "вкрасти" подання 
{ 
a.elem = nullptr; // нічого не залишити позаду
} 


От і все. Коли компілятор бачить return res; він розуміє, що res скоро буде знищений. Він не буде використовуватися після return. Тоді він застосовує конструктор переміщення замість копіювання для передачі значення, що повертається. Для

Matrix r = a+b; 


res всередині operator+() стає порожнім. Деструктор залишається зробити всього нічого, а елементами res тепер володіє r. Ми отримали елементи результату (це могли бути мегабайти пам'яті) з функції (operator+()) в змінну. І зробили це з мінімальними витратами.

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

Крім того, семантика переміщень працює і для присвоювання, тому в разі

r = a+b; 


ми отримуємо оптимізацію переміщенням для оператора присвоювання. Оптимізувати присвоювання компілятору складніше.

Частенько нам навіть не доведеться визначати всі ці операції копіювання і переміщення. Якщо клас складається з членів, які ведуть себе, як годиться, ми можемо просто покластися на операції за замовчуванням. Приклад:

class Matrix { 
vector<double> elem; // елементи
int nrow; // кількість рядків
int ncol; // кількість стовпців
public: 
Matrix(int nr, int nc) // constructor: allocate elements 
:elem(nr*nc), nrow{nr}, ncol{nc} 
{ } 
// ... 
}; 


Цей варіант веде себе так само, як попередній, крім того, що він краще обробляє помилки і займає трохи більше місця (вектор — це звичайно три слова).

Як щодо об'єктів, які не є обробниками? Якщо вони невеликі, типу int або complex, не турбуйтеся. В іншому випадку зробіть їх обробниками або повертайте їх через розумні покажчики unique_ptr і shared_ptr. Не користуйтесь «голими» операціями new і delete. На жаль, Matrix з прикладу не входить в стандартну бібліотеку ISO C++, але для нього є кілька бібліотек. Наприклад, пошукайте «Origin Matrix Sutton» і зверніться до 29 главі книги The C++ Programming Language (Fourth Edition) за коментарями щодо її реалізації.

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

0 коментарів

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