en

Завдання для самостійної роботи

Використання модулів і лямбда-виразів

1 Завдання на роботу

1.1 Представлення й обробка даних про студентів з використанням засобів Стандартної бібліотеки шаблонів, модулів та лямбда-виразів

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

1.2 Використання лямбда-виразів для зворотного виклику

Розв'язати завдання 1.3 четвертої лабораторної роботи. Замість функціональних об'єктів використовувати лямбда-вирази. Функцію, яка обчислює необхідний результат відповідно до індивідуального завдання, розташувати в окремому модулі. Замість підключення заголовних файлів використовувати імпорт з модулів.

2 Методичні вказівки

2.1 Додаткові можливості мови C++ у новітніх версіях

Вперше мову C++ було стандартизовано в 1998 році. У 2003 році було опубліковано нову версію стандарту C++, яка виправила проблеми, виявлені в C++98.

Дванадцятого серпня 2011 року було затверджено новий офіційний стандарт мови C++. Нова стандартизована версія мови включає багато цікавих можливостей:

  • Автоматичне визначення типів
  • Списки ініціалізації
  • Цикл for, побудований на діапазоні
  • Лямбда-функції й вирази
  • Альтернативний синтаксис функцій
  • Розширення синтаксису конструкторів
  • Явне перевизначення віртуальних функцій (модифікатор override після заголовку перевизначеної функції)
  • Константа для нульового вказівника nullptr
  • Строго типізовані переліки
  • Модифікатор виразів constexpr
  • Локальні й безіменні типи як аргументи шаблонів
  • Символи і рядки в Unicode
  • "Сирі" рядки (Raw string literals)
  • Створення можливості реалізації прибирання сміття
  • Використання атрибутів компілятора

Є також додаткові синтаксичні нюанси й відмінності у внутрішній організації ядра мови.

У версіях C++14 і C++17 було розширено сферу й уточнення роботи нових синтаксичних конструкцій, а також розширені можливості Стандартної бібліотеки.

Стандарт C++20 затверджено 4 вересня 2020 року. Його було офіційно опубліковано в грудні 2020 року. Серед нових можливостей слід відзначити такі:

  • Концепції (concept)
  • Модулі
  • Тришляхове порівняння
  • Нові стандартні атрибути
  • Негайні функції (immediate functions) з модифікатором consteval

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

Наступний стандарт повинен бути опублікований у 2023 році.

Нижче будуть розглянуті деякі з перелічених новацій.

2.2 Автоматичне визначення типів

Як вже раніше зазначалося, у версії C++11 додано механізм автоматичного визначення типів який розглядався раныше. Цей механізм дозволяє компілятору створювати локальні змінні, тип яких залежить від типу значення, яким ініціалізують змінну. Для опису таких змінних застосовують ключове слово auto. Наприклад:

auto i = 10;      // ціла змінна
auto s = "Hello"; // вказівник на символ

Такі змінні обов'язково повинні бути ініціалізовані.

У наведеному вище прикладі використання auto не є дуже виправданим, але для складних імен типів такий підхід є достатньо зручним:

map<string, int> mm;
map<string, int>::iterator iter1 = mm.begin(); // попередня форма
auto iter2 = mm.begin(); // нова форма

З автоматичним визначенням типів пов'язаний механізм отримання інформації про тип змінної для подальшого використання цього типу. Цей механізм реалізований ключовим словом decltype. Наприклад, можна створити змінну того ж типу, що й попередня:

long long int n = 5000000000;
decltype(n) m;

Окрім локальних змінних, auto можна використовувати для опису типу результату функцій і функцій-елементів, якщо вони не віртуальні:

auto sum(int a, int b)
{
    return a + b;
}

class AutoTest
{
public:
    auto product(int a, int b)
    {
        return a * b;
    }
};

Можливість опису типу функції за допомогою auto додана починаючи з C++14.

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

2.3 Списки ініціалізації

Традиційно списки ініціалізації використовують для ініціалізації масивів і структур. Наприклад, в усіх попередніх версіях можна було здійснити таку ініціалізацію:

struct SomeData
{
    double d;
    int i;
};

SomeData data  = { 0.1, 1 }; 
SomeData arr[] = { { 0.0, 0 },
                   { 0.1, 1 },
                   { 0.2, 2 } };

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

vector<int> a({ 1, 2 });
vector<int> b = { 3, 4 };

Список ініціалізації можна обходити за допомогою циклу, побудованого на діапазоні. Можна створити функцію з відповідним параметром, наприклад:

int sum(std::initializer_list<int> inilist)
{
    int result = 0;
    for (const int &k : inilist)
    {
        result += k;
    }
    return result;
}

void main()
{
    std::cout << sum({ 1, 10, 12, 23 }); //46
}

2.4 Лямбда-вирази як функціональні об'єкти

Стандарт мови C++11 надає синтаксис лямбда-виразів, який був розглянутий у лабораторній роботі № 4 попереднього семестру.

Лямбда-вирази – це не тільки анонімні функції, але й функціональні об'єкти. Вони можуть зберігати деякі дані, скопійовані з поточного контексту. Ця функція називається "захоплення контексту" і використовується для створення змінних, значення яких зберігаються між викликами функцій. Наступний приклад показує використання елементу списку захоплення (значення k додається до кожного елементу масиву):

int k = 10;
int a[] = { 1, 2, 3 };
for_each(a, a + 3, [k](int &x) { x += k; }); 
// нові значення елементів масиву: 11 12 13

Можна також створити посилання на змінну. Так можна реалізувати приклад, пов'язаний зі зберіганням елементів вектора в текстовому файлі за допомогою алгоритму for_each():

vector<int> a = { 1, 2, 3, 4 };
ofstream out("result.txt");
for_each(a.begin(), a.end(), [&out](const int& i) { out << i << endl; });

2.5 Використання ключового слова constexpr

У всіх версіях C++ є можливість створення констант двох типів:

  • константи, які може обчислити компілятор з подальшим використанням для визначити розмірів масиву, як аргумент шаблону тощо (константи часу компіляції);
  • константи, які можуть бути обчислені лише під час виконання програми (константи часу виконання).

Наприклад, константу m можна використовувати для створення масиву, а n – ні:

const int m = 2 + 3;
const int n = 5.0;
int a[m];
int b[n]; // error

Також не можна використовувати результат функції для створення констант часу компіляції:

int mValue()
{
    return 5;
}

int main()
{
    const int m = mValue();
    double arr[m]; // error
}

Не можна також використовувати статичні елементи структур і класів:

struct IntValue
{
    static int n = 5;
};

. . .

const int n = IntValue::n;
int b[n]; // error 

Наявність двох варіантів констант, визначення яких дуже схоже, вносить небажану плутанину. Крім того, обчислення деяких складних виразів під час компіляції мого б підвищити загальну ефективність роботи програми. Тому починаючи з версії C++11 додано нове ключове слово constexpr, яке дозволяє явно визначати вирази, які теоретично може обчислити компілятор. Наприклад:

constexpr int n = 5.5 * 2.0;
int b[n];

У подальших версіях були розширені можливості використання constexpr.

Модифікатор constexpr можна додавати до елементів типів даних і функцій. У наведеному нижче прикладі функція factorial() може бути застосована як під час виконання, так і під час компіляції для отримання значення константи. Всередині функції можуть бути розташовані цикли:

#include <iostream>

constexpr int factorial(int n)
{
    if (n < 0 || n > 12)
    {
        throw std::range_error("argument out of range");
    }
    int result = 1;
    for (int i = 1; i <= n; i++)
    {
        result *= i;
    }
    return result;
}

int main()
{
    int x = 4;
    std::cout << factorial(x) << std::endl;
    constexpr int m = factorial(5); // можна const int m = factorial(5);
    double arr[m]; // масив зі 120 елементів
    arr[119] = 3;
    std::cout << arr[119] << std::endl;
    return 0;
}

У функції можна задіяти рекурсію для реалізації функції factorial():

constexpr int factorial(int n)
{
    if (n < 0 || n > 12)
    {
        throw std::range_error("argument out of range");
    }
    if (n < 2) {
        return 1;
    }
    return n * factorial(n - 1);
}

Результат буде аналогічний.

Оскільки під час компіляції обробка винятків не можлива, застосування некоректного аргументу на кшталт

constexpr int m = factorial(14);

призведе до помилки компіляції.

Окрім модифікатора constexpr у версії C++20 додано також модифікатор consteval. Вирази, помічені цим модифікатором, можна використовувати тільки під час компіляції. Наприклад, відповідну функцію може викликати тільки компілятор:

consteval double reciprocal(double x) 
{
    return 1 / x;
}

. . .

constexpr int n = reciprocal(0.25); // OK
double arr[n]; // масив зі 4 елементів
double x = 2;
double y = reciprocal(x); // error

Спроба виклику функції reciprocal(x) призвела до помилки компіляції.

2.6 Семантика переміщення. Використання rvalue-посилань

Багато новацій C++11 націлено на підвищення ефективності виконання програм. Семантика переміщення передбачає, що в тих випадках, коли під час присвоєння значення змінної-джерела далі не використовується, замість копіювання може здійснюватися переміщення. Спеціальна функція std::move() застосовується з цією метою. Крім того, до синтаксису додане так зване rvalue-посилання. Під час його опису замість одного знаку & використовують &&.

Використання таких посилань зменшує необхідну кількість різних комірок пам'яті. Наприклад, для змінної c не створюється нова комірка пам'яті: вона використовує комірку, створену для константи:

int&& c = 10;

У наведеному нижче фрагменті змінна посилається на тимчасовий об'єкт, який містить значення, обчислене функцією sin():

double x;
cin >> x;
double&& y = sin(x);

Можна переписати раніше реалізовану функцію swap() для обміну значень двох змінних в такий спосіб:

void swap(int& a, int& b)
{
    int&& c = std::move(a);
    a = std::move(b);
    b = std::move(c);
}

Тепер під час обміну не створюється зайва змінна.

2.7 Розширення синтаксису конструкторів

Стандарт мови C++11 розширює можливості конструкторів. Раніше було розглянуто можливість виклику конструкторів з інших конструкторів у списку ініціалізації. Крім того, додано так званий "конструктор переміщення". Цей конструктор не створюється автоматично. Синтаксис конструктору переміщення для класу Demo буде таким:

Demo(Demo&& d);

Для реалізації семантики переміщення слід також перевизначити операцію присвоєння з переміщенням:

Demo operator=(Demo&& d);

Крім того, до заголовків конструкторів можна додавати модифікатори default і delete. Це спрощує вибір між конструктором без параметрів і конструктором з усталеними параметрами як усталеного, а також, наприклад, дозволяє заборонити копіювання об'єктів:

Demo(int k = 0) { }
Demo() = default;
Demo(const Demo&) = delete;

Синтаксис "видаленої функції" можна також застосувати до інших функцій-елементів, наприклад, якщо ми хочемо заборонити використання автоматично визначеної операції:

class DeleteTest
{
public:
    DeleteTest operator=(const DeleteTest& dt) = delete;
};

int main()
{
    DeleteTest dt1, dt2;
    dt1 = dt2; // Синтаксична помилка
}

2.8 Використання модулів

Реалізація парадигми модульного програмування мовами C/C++ через використання заголовних файлів і файлів реалізації має істотні недоліки:

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

Подолати ці недоліки покликані модулі – спеціальна нова конструкція мови, яка з'явилася в C++20.

Модуль складається з інтерфейсної частини (interface unit) та частини реалізації (implementation unit). Інтерфейсна одиниця модуля – це файл з сирцевим кодом, який містить заголовок, оголошення типів, констант, змінних і функцій, які експортує цей модуль, а також, якщо необхідно, типів, констант, змінних і функцій для внутрішнього використання. Наприклад, інтерфейсна частина модуля може бути такою:

export module my.tools;  // заголовок модуля

export int one();
export int two() 
{ 
    return 2;
}

export const int three = 3;
export int count;

int zero()  // для внутрішнього вжитку, не експортується
{
    return 0;
}

Примітка: внутрішнє ім'я модуля не пов'язане з ім'ям файлу, крапка в імені додана для зручності.

Одиниця реалізації – це файл з сирцевим кодом, в якому вказана приналежність до певного модуля та реалізовані функції, оголошені в інтерфейсній одиниці. Додавати нові оголошення для експорту заборонене.

module my.tools;  // вказуємо, до якого модуля відноситься цей файл

int one()
{
    return zero() + two() - 1;
}

У функції main() (поза модулем) можна імпортувати й використати типи, константи, змінні й функції, які експортує модуль:

import my.tools;

int main()
{
    int k = one();    // OK
    k += zero();      // error
    count = k + three;
    return count;
}

Можна також створювати модулі, які складаються з окремих частин (partition files).

Для того, щоб створити модуль у середовищі Visual Studio 2022, спочатку необхідно додати інтерфейсну частину модуля (Project | Add New Item... | C++ Module Interface Unit). Це – файл, який має розширення ixx. Далі, якщо треба, додаємо файл реалізації (Project | Add New Item... | C++ File (.cpp)).

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

import std;

Примітка: у попередніх версіях назва цього модуля була std.core.

Для того, щоб код з подібним імпортом було скомпільовано й виконано, необхідно виконати певні налаштування середовища Visual Studio 2022. За допомогою засобів програми Visual Studio Installer, яка застосовувалася під час встановлення Visual Studio, слід додати необхідні засоби. Завантаживши програму, на закладці Available слід натиснути кнопку Install. Далі вибираємо панель Individual components і відшукуємо компонент C++ Modules for v143 build tools і натиснути Modify.

Крім того, слід налаштувати властивості проекту Project | Properties, далі у вікні Property Pages розкриваємо Configuration Properties | C/C++ | Language і встановлюємо такі опції:

  • C++ Language Standard: Preview - Features from the Latest C++ Working Draft (/std:c++latest)
  • Enable Experimental C++ Standard Library Modules: Yes (/experimental:module)

Тепер можна створити програму, яка використовує модуль std:

import std;

int main()
{
    std::cout << "Hello Modules!\n";
    return 0;
}

Очікується розширення використання модулів у подальших версіях C++.

3 Приклади програм

3.1 Створення класу з конструктором типу initializer_list

Припустимо, ми створюємо власний клас-оболонку для вектора і хочемо, щоб вектор можна було ініціалізувати списком. Відповідний конструктор можна додати до шаблону класу:

#include <iostream>
#include <vector>

template <typename T> class Array1D
{
    friend std::ostream& operator<<(std::ostream& out, const Array1D<T> a)
    {
        for (const T &elem : a.vect)
        {
            out << elem << " ";
        }
        return out;
    }
private:
    std::vector<T> vect;
public:
    Array1D(std::initializer_list<T> list)
    {
        std::vector<T> newVect(list);
        vect = std::move(newVect);
    }
};

int main()
{
    Array1D<int> a = { 1, 2, 3 };
    std::cout << a;
    return 0;
}

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

3.2 Використання модулів, засобів Стандартної бібліотеки шаблонів і лямбда-виразів для представлення міст і країни

У лабораторній роботі №5 було наведено приклад створення класів для представлення міста і країни з використанням засобів Стандартної бібліотеки шаблонів. Тепер класи City та Country можна розмістити в окремих модулях. У проєкті з підтримкою останніх можливостей C++ і модуля стандартної бібліотеки (інструкцію з налаштування середовища наведено у 2.8) створюємо новий модуль city з інтерфейсним файлом City.ixx:

export module city;

import std;

using std::string;

export class Country;

// Клас для представлення міста
export class City
{
    // Перевантажений оператор для виведення в потік
    friend std::ostream & operator<<(std::ostream & out, const City & city);
private:
    string name = "";          // назва міста
    Country* country = nullptr;// вказівник на країну розташування
    string region = "";        // назва регіону
    int population = 0;        // населення
public:
    // Конструктори:
    City() { }
    City(const char* name, const char* region, int population) :
        name(name), country(country), region(region), population(population) { }

    // Гетери:
    string getName() const { return name; }
    Country* getCountry() const { return country; }
    string getRegion() const { return region; }
    int getPopulation() const { return population; }

    // Сетери:
    void setName(string name) { this->name = name; };
    void setName(const char* name) { this->name = name; };
    void setRegion(string region) { this->region = region; };
    void setRegion(const char* region) { this->region = region; };
    void setCountry(Country* country) { this->country = country; }
    void setPopulation(int population) { this->population = population; }
};

Перевантажену операцію виведення в потік визначаємо в одиниці реалізації (City.cpp):

module city;

import country;

// Перевантажений оператор для виведення в потік:
std::ostream& operator<<(std::ostream& out, const City& city)
{
    out << "Мiсто: " << city.name << "\t Країна: " << city.country->getName() 
        << "\t Регіон: " << city.region << "\t Населення: " << city.population;
    return out;
}

Також створюємо модуль country з інтерфейсним файлом Country.ixx:

export module country;

import std;
import city;

using std::string;
using std::vector;

// Клас для представлення країни
export class Country
{
    // Перевантажений оператор для виведення в потік
    friend std::ostream& operator<<(std::ostream& out, const Country& country);
private:
    string name;               // назва країни
    vector<City> cities = { }; // вектор міст
public:
    // Конструктори:
    Country() { }
    Country(string name) : name(name) { }
    Country(const char* name) : name(name) { }
    string getName() const { return name; } // гетер

    // Перевантажений оператор для отримання елементів масиву
    City operator[](int index) const { return cities[index]; }

    // Сетери:
    void setName(string name) { this->name = name; }
    void setName(const char* name) { this->name = name; }

    void addCity(City city); // Додавання міста
    void sortByPopulation(); // Сортування за населенням
};

В одиниці реалізації (файл Country.cpp) визначаємо перевантажену операцію виведення в потік, додавання міста і сортування вектора міст. В операторній функції виведення використовуємо цикл, побудований на діапазоні. У функції додавання використано семантику переміщення. Для сортування міст в алгоритмі sort() стандартної бібліотеки створюємо лямбда-вираз:

module country;

using std::endl;

// Перевантажений оператор для виведення в потік:
std::ostream& operator<<(std::ostream& out, const Country& country)
{
    out << country.name << endl;
    for (const City& city : country.cities)
    {
        out << city << endl;
    }
    out << endl;
    return out;
}

void Country::addCity(City city) // Додавання міста
{
    city.setCountry(this); 
    cities.push_back(std::move(city));
}

void Country::sortByPopulation() // Сортування за населенням
{
    std::sort(cities.begin(), cities.end(),
        [](City c1, City c2) { return c1.getPopulation() < c2.getPopulation(); });
}

У файлі з функцією main() імпортуємо необхідні модулі та демонструємо можливості класів:

import std;
import city;
import country;

#include <locale.h>

int main()
{
    std::setlocale(LC_ALL, "UKRAINIAN");
    Country country = "Україна"; // створюємо об'єкт "Країна",
                                 // викликаємо конструктор з одним параметром
    // Створюємо й додаємо міста:
    country.addCity(City("Харкiв", "Харкiвська область", 1421125));
    country.addCity(City("Полтава", "Полтавська область", 284942));
    country.addCity(City("Лозова", "Харкiвська область", 54618));
    country.addCity(City("Суми", "Сумська область", 264753));

    std::cout << country << std::endl;   // виводимо всі дані
    std::cout << country[0] << std::endl;// виводимо інформацію про місто за індексом
    std::cout << std::endl;
    country.sortByPopulation();          // здійснюємо сортування
    std::cout << country << std::endl;   // виводимо всі дані

    return 0;
}

Примітка: для забезпечення можливості застосування константи LC_ALL необхідно включити заголовний файл locale.h.

3.3 Використання модулів, шаблонів і лямбда-виразів у задачі розв'язання довільного рівняння

Раніше розглядалась задача розв'язання довільного рівняння методом дихотомії. Зокрема, в прикладі 3.3 лабораторної роботи № 3 було наведення рішення, побудоване на використанні шаблонів. Цей приклад можна модифікувати, додавши модулі C++20, а також продемонструвавши роботу, визначаючи функції через лямбда-вирази. Створюємо новий модуль. Інтерфейсна одиниця цього модуля (файл Dichotomy.ixx) міститиме модуль з назвою dichotomy і кодом функції розв'язання рівняння, яку ми експортуємо:

export module dichotomy;

export template <typename F> double root(F f, double a, double b, double eps = 0.001)
{
    double x;
    do
    {
        x = (a + b) / 2;
        if (f(a) * f(x) > 0)
        {
            a = x;
        }
        else
        {
            b = x;
        }
    } 
    while (b - a > eps);
    return x;
}

Оскільки функція root() – шаблонна, вона реалізована в інтерфейсній одиниці. Файл реалізації не потрібен.

У файлі з функцією main() ми імпортуємо модулі std і dichotomy, першу функцію, яка визначає ліву частину рівняння, ми визначаємо лямбда-виразом. Ліва частина другого рівняння – стандартна функція sin(x). Код програми буде таким:

import std;
import dichotomy;

#include <math.h>

int main()
{
    std::cout << std::setprecision(14); // визначення кількості символів для відображення
    // Ліву частину одного з рівнянь ми визначаємо лямбда-виразом:
    std::cout << root([](double x) { return x * x - 2; }, 0, 2, 0.1E-14) << std::endl;
    // Ліва частина другого рівняння - стандартна функція sin(x):
    std::cout << root(sin, 1, 4, 0.1E-14) << std::endl;
    return 0;
}

Результат роботи програми – виведення значень квадратного кореня з 2 і число π з достатньою точністю:

1.4142135623731
3.1415926535898

Примітка: для того, щоб запобігти помилкам компіляції, замість заголовного файлу cmath ми використовуємо math.h.

4 Вправи для контролю

  1. Створити програму, в якій всі розрахунки здійснюються під час компіляції.
  2. Розробити програму, в якій вводиться послідовність, і формується нова послідовність з квадратів вихідних значень. Використовувати алгоритм transform() і лямбда-вирази.

5 Контрольні запитання

  1. Які нові можливості надає стандарт C++11?
  2. Які нові можливості надає стандарт C++20?
  3. Що таке автоматичне визначення типів?
  4. Що таке тип long long?
  5. Що таке список ініціалізації?
  6. Що таке лямбда-вираз?
  7. Які є способи використання лямбда-виразів?
  8. З якою метою використовують ключове слово constexpr?
  9. Що таке семантика переміщення?
  10. Для чого використовують rvalue-посилання?
  11. Що таке конструктор переміщення?
  12. Які переваги модулів у порівнянні з заголовними файлами?
  13. Що таке інтерфейсна одиниця модуля?
  14. Що таке одиниця реалізації модуля?

 

up