Лабораторна робота 6

Робота зі вказівниками, рядками та файлами

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

1.1 Сума мінімального і максимального елементів

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

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

Для введення і виведення, а також роботи з файлами слід застосовувати засоби C++.

1.2 Перемноження матриць

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

1.3 Перевірка паліндромів

Прочитати з клавіатури речення (масив символів) за допомогою функції getline(), перевірити, чи є воно паліндромом і вивести відповідне повідомлення. Рекомендація: під час введення не використовувати великих літер.

Паліндром – це речення, що однаково читається в обох напрямках (зліва направо та справа наліво), наприклад, "аби ріці риба". При цьому пропуски та розділові знаки не враховуються. В програмній реалізації їх доцільно видалити та здійснювати перевірку отриманого рядка.

1.4 Індивідуальне завдання

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

У програмі виконати такі дії:

  • Створити в динамічній пам'яті двовимірний масив цілих елементів, прочитавши його розміри з файлу.
  • Прочитати елементи масиву з файлу рядок за рядком.
  • Створити в динамічній пам'яті другий масив необхідних розмірів.
  • Обробити перший масив та заповнити другий відповідно до індивідуального завдання попередньої лабораторної роботи.
  • Створити текстовий файл і записати в нього елементи обробленого першого масиву.
  • Створити інший текстовий файл і записати в нього елементи другого масиву.
  • Звільнити пам'ять, яку займали масиви.

Окремі дії реалізувати в різних функціях. Не використовувати глобальні змінні.

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

1.5 Використання засобів мови C (додаткове завдання)

Створити застосунок мовою C, в якому реалізувати завдання 1.1, наведене вище.

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

2.1 Визначення вказівників

2.1.1 Опис вказівників

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

int *p;

Вказівник, значення якого дорівнює нулю, має назву нульового вказівника (null pointer). Всі вказівники, коли вони створюються, одразу або пізніше повинні бути ініціалізовані. Якщо певне значення поки не можна визначити, вказівнику доцільно присвоїти нуль. Вказівник, який не ініціалізовано, називається диким вказівником (wild pointer). Дикі вказівники дуже небезпечні.

Щоб присвоїти значення або ініціалізувати вказівник, перед ім'ям змінної, адреса якої присвоюється вказівнику, розташовують оператор отримання адреси (&). Наприклад:

int i = 5;
int *p = &i;

Щоб отримати доступ до даних, що зберігаються за адресою, можна використовувати так зване розіменування (dereferencing). Для розіменування вказівника оператор розіменування (*) встановлюється перед ім'ям вказівника. Наприклад:

int j = *p + i; // j = 5 + 5 = 10

Фактично *p є синонімом i. З *p можна працювати як зі звичайною цілою змінною.

*p = 6; // i = 6

Можна створити масив указівників:

int i = 0;
int j = 1;
int k = 10;
int *pa[3];
pa[0] = &i;
pa[1] = &j;
pa[2] = &k;
for (int i = 0; i < 3; i++)
{
    cout << *pa[i] << ' '; // 0 1 10
}

Можна здійснити ініціалізацію масиву вказівників адресами змінних:

int *pb[] = {&i, &j, &k};
for (int i = 0; i < 3; i++)
{
    cout << *pb[i] << ' '; // 0 1 10
}

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

int k = 1;
double d = k;
int j;
j = d;
int *pk = &k;
double *pd = &d;
int *pj;
pj = pk;         // OK. Два вказівника вказують на одну змінну
pd = pk;         // Синтаксична помилка
pj = pd;         // Синтаксична помилка
float *pf = pd;  // Синтаксична помилка

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

Можна створювати вказівники на константний об'єкт: значення, на яке вказує вказівник, не може бути змінено, але сам вказівник може бути переміщений на іншу змінну або константу.

int k = 4;
const int *pk = &k; // Вказівник на константу
k = 5;
cout << *pk;        // 5
*pk = 6;            // Помилка!

Константний вказівник (ключове слово const стоїть після *) – це вказівник, який не може бути змінений. Проте, значення, на яке вказує вказівник може бути змінене. Константні вказівники обов'язково повинні бути ініціалізовані:

int i = 1;
int * const cp = &i;
cout << *cp;         // 1
int j = 2;
cp = &j;             // Помилка!

Константний вказівник доцільно використовувати, у випадках, коли бажано не загубити адресу важливого об'єкта.

Можна також створювати константні вказівники на константні об'єкти:

int i = 1;
const int * const cp = &i;

2.1.2 Вказівники як параметри й результат функцій

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

void swap(int *p1, int *p2)
{
    int x = *p1;
    *p1 = *p2;
    *p2 = x;
}

Тепер функцію swap() можна викликати з функції main():

void main()
{
    int a = 1;
    int b = 2;
    swap(&a, &b); // Отримуємо адреси змінних
    cout << a << endl; // 2
    cout << b << endl; // 1
}

Можна також створювати функції для роботи з масивом вказівників:

#include<iostream>
using namespace std;

double sum(double* arr[], int size)
{
    double result = 0;
    for (int i = 0; i < size; i++)
    {
        result += *arr[i];
    }
    return result;
}

int main()
{
    double x = 1;
    double y = 2;
    double z = 2.5;
    double* a[] = { &x, &y, &z };
    cout << sum(a, 3); // 5.5
    return 0;
}

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

int* f()
{
    int k;
    return &k; // Помилка: k знищується після виходу з функції
}

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

int* f()
{
    static int k;
    return &k; // OK
}

int main()
{
    *f() = 10;
    cout << *f(); // 10
    return 0;
}

2.1.3 Вказівник void*

Існує особливий тип вказівників: void*. Можна привести вказівники будь-яких типів до void*. Змінні типу void* використовуються для зберігання вказівників різних типів. У наведеному нижче прикладі, масив void* містить адреси різних змінних:

int i = 0;
double d = 1.5;
char c = 'A';
void *p[4] = {&i, &d, &c}; // p[3] = nullptr
double *pd = &d;
p[3] = pd;

Не можна розіменовувати void*. Для того, щоб отримати вказівники конкретних типів, слід привести вказівники до необхідних типів звичайними засобами або за допомогою оператора static_cast:

double *pd = (double*) p[1];
cout << *pd << endl; // 1.5
char *pc = static_cast<char*>(p[2]);
cout << *pc << endl; // 'A'

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

2.2 Зв'язок масивів зі вказівниками

2.2.1 Загальні концепції

У C++ ім'я масиву є постійним вказівником на перший елемент масиву. Таким чином, у визначенні

int a[50];

a – це константний вказівник на &a[0], тобто на початковий елемент масиву. Можна використовувати імена масивів як константні вказівники й навпаки:

int a[5] = {1, 2, 4, 8, 16};
int *p = a;
cout << *a << endl;   // 1
cout << p[2] << endl; // 4

Квадратні дужки можна завжди застосовувати замість розіменування, навіть якщо йдеться не про масиви:

int i = 5;
int *p = &i;
p[0] = 6;
cout << i; // 6

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

2.2.2 Адресна арифметика

До вказівників можна застосувати деякі операції. Арифметика вказівників обмежується додаванням, відніманням і порівнянням. Під час виконання арифметичних операцій з вказівниками передбачається, що вказівники вказують на масив об'єктів. Додавши ціле значення до вказівника, ми переміщуємо його на відповідне число об'єктів у масиві. Якщо, наприклад, тип має розмір 10 байтів, а потім ми додали ціле число 5, вказівник переміщується на 50 байтів в пам'яті. Можна застосувати інкремент і декремент, а також операції складеного присвоювання для неконстантних вказівників.

int a[5] = { 1, 2, 4, 8, 16 };
int *p = a;               // p вказує на a[0]
cout << *(a + 3) << endl; // 8
p++;                      // p вказує на a[1]
cout << *p << endl;       // 2
p += 3;                   // p вказує на a[4]
cout << *p << endl;       // 16
p--;                      // p вказує на a[3]
cout << *p << endl;       // 8
cout << *(p - 2) << endl; // 2
a++;                      // Помилка! a є константним вказівником

Різниця між двома вказівниками на різні елементи масиву повертає кількість елементів, які розташовані між цими вказівниками (включаючи перший і не включаючи останній). Наприклад,

int a[5] = { 1, 2, 4, 8, 16 };
int *p1 = a;              // p1 вказує на a[0]
int *p2 = a + 3;          // p2 вказує на a[3]
cout << p2 - p1           // 3;

Різниця між двома вказівниками має сенс тільки тоді, коли обидва вказівники вказують на елементи одного масиву.

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

int a[5] = {1, 2, 4, 8, 16};
int *p3 = a + 5;           // Такого елемента немає

Розіменування p3 є небезпечним.

2.2.3 Цикли з використанням вказівників

Замість циклів, побудованих на використанні індексів, можна як параметр використовувати вказівник. Це може трохи підвищити ефективність програми.

У наведеному нижче прикладі ми здійснюємо обхід масиву для заповнення і виведення за допомогою арифметики адрес:

#include <iostream>

int main()
{
    const int n = 4;
    double a[n] = { };
    double item = 1; 
    for (double* p = a; p < a + n; p++)
    {
        *p = item;
        item *= 2;
    }
    for (const double* p = a; p < a + n; p++)
    {
        std::cout << *p << " "; // 1 2 4 8
    }  
}

Модифікатор const перед описом вказівника не є обов'язковим, але рекомендованим у випадках, коли розіменований елемент масиву застосовують тільки для читання.

Інший приклад демонструє проходження елементів масиву у зворотному порядку:

#include <iostream>

int main()
{
    double a[] = { 1, 2, 4, 8 };
    int n = sizeof(a) / sizeof(double);
    double sum = 0;
    for (const double *p = a + n - 1; p >= a; p--)
    {
        std::cout << *p << " ";
    }
}

Так можна знайти суму елементів з парними індексами:

#include <iostream>

int main()
{
    const int n = 4;
    double a[] = { 1, 2, 4, 8};
    double sum = 0;
    for (double *p = a; p < a + n; p += 2)
    {
        sum += *p;
    }
    std::cout << sum; // 5
}

2.2.4 Використання вказівників для передачі масивів у функції

Вказівники можна використовувати для опису параметра функції типу масиву. Саме вказівники зазвичай використовують замість масивів під час опису параметрів. Наприклад:

#include <iostream>

double sum(double *arr, int n)
{
    double result = 0;
    for (int i = 0; i < n; i++)
    {
        result += arr[i];
    }
    return result;
}

int main()
{
    double a[] = { 1, 2, 4, 8, 16 };
    double y = sum(a, 5);
    std::cout << sum(a, 5) << std::endl; // 31
    std::cout << sum(a, 4) << std::endl; // 15
    return 0;
}

Вихідний масив може бути змінений всередині функції:

void modifyStartingElement(int *p)
{
    p[0] = 0;
}

void main()
{
    int a[] = {1, 2, 3};
    modifyStartingElement(a);
    for (int i = 0; i < 3; i++)
    {
        cout << a[i] << ' '; // 0 2 3
    }
}

Для того, щоб випадково не змінити значення елементів масиву, його можна передавати через вказівник на константний об'єкт:

void f(const int* p)
{
    p[0] = 0; // Помилка компіляції
    ...
}

Завдяки вказівникам можна передавати багатовимірні масиви як параметри. Але для забезпечення гнучкості слід описувати такі параметри як вказівники на дані (одновимірний масив). Під час виклику функції необхідно явно перетворювати тип. Наприклад, наведена нижче функція може бути використана для обчислення суми всіх елементів двовимірного масиву з m рядків і n стовпців. Реалізація функції передбачає обчислення індексу з урахуванням розташування елементів двовимірного масиву:

double sum(double* a, int m, int n)
{
    double result = 0;
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++)
        {
            result += a[i * n + j];
        }
    }
    return result;
}

int main()
{
    double arr[][3] = { { 1, 2, 3 },
                        { 4, 5, 6 } };
    cout << sum((double*) arr, 2, 3) << endl; // явне перетворення типів
    return 0;
}

2.3 Використання динамічної пам'яті

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

  • Глобальні змінні знаходяться в глобальному сегменті пам'яті.
  • Регістри утворюють особливу область пам'яті, вбудовану в центральний процесор.
  • Стек викликів (call stack) – це спеціальна область пам'яті, виділена для зберігання даних окремих функцій.
  • Решта пам'яті, розподіленої для програми, – це так звана динамічна пам'ять (free store).

Програмісти можуть використовувати динамічну пам'ять для контрольованого виділення пам'яті й звільнення змінних з пам'яті. Оператор new використовують для розташування змінних у динамічній пам'яті. Операція new повертає вказівник на об'єкт розташований в динамічній пам'яті.

int *p = new int;
*p = 64;
. . .// *p можна вільно використовувати, поки пам'ять не буде звільнена

Змінна може бути ініціалізована початковим значенням:

int *p = new int(64); // *p = 64

Змінна, яка була створена в динамічній пам'яті, повинна бути звільнена за допомогою оператору delete:

delete p;

Після звільнення пам'яті, пам'ять, що раніше була виділена для змінної може бути використана для розташування інших змінних.

Якщо після імені типу в операції new розташувати квадратні дужки з цілим значенням всередині, в динамічній пам'яті можна розмістити масив відповідного типу. Ціле значення у квадратних дужках – це кількість елементів, для визначення якої можна використовувати будь-які вирази або змінні, що приводяться до int. Після створення, використання динамічних масивів і звичайних масивів практично не відрізняються:

int n;
cin >> n;
double *pa = new double[n];
for (int i = 0; i < n; i++)
{
    pa[i] = 0;
}
. . .

Динамічну пам'ять, яка була виділена для масиву, звільняють в такий спосіб:

delete [] pa;

Необхідно стежити, щоб уся пам'ять, виділена за допомогою операції new, була звільнена за допомогою операції delete.

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

Якщо в динамічній пам'яті створити масив вказівників, а потім створити кілька одновимірних масивів і записати їх адреси в масив вказівників, ми отримаємо масив масивів, з яким можна працювати як зі звичайним двовимірним масивом:

cin >> m >> n;
double** a = new double*[m];
for (int i = 0; i < m; i++)
{
    a[i] = new double[n];
}
for (int i = 0; i < m; i++)
{
    for (int j = 0; j < n; j++)
    {
        a[i][j] << i + j;
    }
}

Дуже важливо звільнити пам'ять в правильному порядку:

for (int i = 0; i < m; i++)
{
    delete[] a[i];
}
delete[] a;

Аналогічно можна створювати масиви з більшою кількістю розмірностей.

Недоліком багатовимірних масивів в динамічній пам'яті є певне зниження ефективності під час роботи, але є істотні переваги:

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

Останню перевагу можна пояснити на такому прикладі. Функція fill() записує вказане значення в усі елементи масиву:

#include <iostream>
using namespace std;

void fill(double **arr, int m, int n, double value) {
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++)
        {
            arr[i][j] = value;
        }
    }
}

int main()
{
    int m, n;
    cin >> m >> n;
    double** a = new double*[m];
    for (int i = 0; i < m; i++)
    {
        a[i] = new double[n];
    }
    fill(a, m, n, 10);
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++)
        {
            cout << a[i][j] << " ";
        }
        cout << endl;
    }
    for (int i = 0; i < m; i++)
    {
        delete[] a[i];
    }
    delete[] a;
}

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

double b[3][4];
fill(b, 3, 4, 10); // помилка компіляції

2.4 Масиви символів

У мові C++ (як і в C) рядок – це масив символів, що закінчуються нульовим символом (символ з кодом 0). Можна створити й ініціалізувати рядок так само як і будь-який інший масив. Наприклад,

char name[] = {'A', 'n', 'd', 'r', 'e', 'w', '\0'};

Останній символ '\0' використовують як ознаку закінчення рядка. Рядок може бути ініціалізований літералом:

char name[] = "Andrew";

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

Мова C++ дозволяє читати масив символів без циклу. Важливо переконатися, що є достатньо пам'яті для розташування символів:

char s[30];
cin >> s;

Вказівники на символ можна ініціалізувати літералами:

char *st = "C++";

Компілятор виділяє пам'ять для зберігання чотирьох символів (включаючи '\0') і записує адресу початкового символу в st.

Стандартна бібліотека C пропонує набір функцій для обробки рядків з завершальним нульовим символом. Наприклад,

// Повертає довжину рядка (без '\0'):
int strlen(const char *s); 

// Порівнює два рядки і повертає від'ємне значення
// якщо s1 менше ніж s2, нуль, якщо вони однакові, 
// і додатне значення в іншому випадку:
int strcmp(const char *s1, const char *s2);

// копіює s2 в s1:    
strcpy(char *s1, const char *s2); 

Функція strcmp() реалізує порівняння рядків за абеткою. Для використання наведених функцій слід підключити заголовний файл <cstring>. Оголошення функцій в цьому файлі розташовані в просторі імен std.

2.5 Використання засобів мови C

2.5.1 Загальні концепції

Мова C була створена на початку сімдесятих років двадцятого століття. Роком виходу мови вважають 1972 рік. Мова C була безпосередньо застосована для створення операційної системи UNIX. Ця операційна система стала прообразом всіх сучасних операційних систем, які підтримують стандарти POSIX, зокрема, всіх версій ОС Linux. Мова була створена Деннісом Рітчі у корпорації Bell Telephone Laboratories (Bell Labs). Пізніше мова була стандартизована. Останній офіційний стандарт – C11.

Мова C не підтримує об'єктно-орієнтованих засобів, просторів імен, лямбда-виразів, шабловнів і винятків. На рівні імперативного та процедурного програмування існують істотні обмеження, пов'язані з відсутністю механізму перевантаження імен функцій, усталених параметрів, inline-функцій, циклів, побудованих на діапазоні, і посилань.

Мова C є менш строго типизованою в порівнянні з C++. Наприклад, можна створювати функції зі змінною кількістю параметрів (varargs), типи яких компілятор не в змозі перевірити. Використання макросів теж є небезпечним з точки зору типів.

Для того, щоб створити програму мовою C в середовищі Visual Studio, після створення консольного застосунку слід видалити з проєкту файл з функцією main() і розширенням .cpp, а потім вручну додати файл (Add | New Item... | C++ File) з розширенням .c.

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

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

Для роботи з цими функціями слід підключити заголовний файл stdio.h. Необхідні функції оголошені у глобальній області видимості.

Примітка. Під час створення програми мовою C++ бажано застосовувати більш сучасний варіант заголовного файлу – cstdio. Оголошення функцій в цьому файлі розташовані в просторі імен std.

Функцію printf() можна використовувати для виведення на консоль. Вона може бути викликана одним або декількома аргументами. Константний рядок може бути виведено на консоль, якщо вказати його як аргумент:

printf("Hello"); // так само, як cout << "Hello";

Якщо необхідно вивести числа, всередині першого аргументу вказують так звані символи форматування. Послідовність форматування починається з символу % з подальшим визначенням розміру і безпосереднім символом форматування. Іноді встановлювати розмір поля не треба. Наприклад:

int k = 12;
printf("%i", k);

Найбільш важливими символами форматування є:

Символи Дані, які виводяться
 %d або %i int
 %c окремий символ
 %e або %E float або double у форматі [-]d.ddd e±dd або [-]d.ddd E ±dd
 %f float або double у форматі [-]ddd.ddd
 %p адреса (вказівник)
 %s рядок виведення
 %u unsigned int
%x або %X int як шістнадцяткове число
 %% символ %

Символ "мінус" перед послідовністю форматування обумовлює вирівнювання по лівому краю.

Функція scanf() дозволяє зчитувати зі стандартного вхідного потоку. Перший параметр – це рядок форматування. Він дозволяє, зокрема, вказувати роздільники. Інші параметри – вказівники на змінні, значення яких повинні бути прочитані. Функція повертає кількість байтів, які читалися. Для читання даних типу double слід використовувати lf замість f. Наприклад:

int n;
float x;
double y;
scanf("%d, %f %lf", &n, &x, &y);
printf("%10d ", n);
printf("%10.5f \n", x);
printf("%-10.5f", y);

Під час введення перші два числа слід розділити комою.

На відміну від C++, мова C не підтримує роботу з потоками cin і cout. Тому найпростіша програма мовою C відрізнятиметься від наведеної раніше:

#include <stdio.h>

int main()
{
    printf("Hello, world!\n");
    return 0;
}

Примітка: варіант підключення cstdio неприйнятний для мови C, яка не підтримує просторів імен.

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

Більш небезпечною є функція scanf(). Некоректне використання цієї функції може призвести до зависання програми. Тому в сучасних версіях Visual Studio спроба скомпілювати програму, яка містить виклики scanf(), спричиняє виникнення помилки компіляції "'scanf': This function or variable may be unsafe". Для того, щоб скомпілювати таку програму, слід заборонити відповідний контроль, додавши першим рядком програми директиву препроцесору #define _CRT_SECURE_NO_WARNINGS. Наведена нижче програма здійснює читання цілого значення, збільшення його на одиницю й виведення результату на консоль:

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>

int main()
{
    int k;
    scanf("%d", &k);
    k++;
    printf("%d", k);
    return 0;
}

Існують також функції getchar() і putchar() для відповідно читання і виведення окремих символів.

2.5.3 Використання макросів

Обидві мови (C і C++) підтримують препроцесорну обробку сирцевого коду. Раніше практично в усіх програмах ми використовували директиву #include. Також відносно часто використовують директиву #define, завдяки якій можна визначити рядок-константу. Приклад такої константи – _CRT_SECURE_NO_WARNINGS. Ця константа була визначена в попередньому прикладі. Для того, щоб легше було відшукувати такі константи, для них зазвичай використовують прописні літери.

Можна видалити певні частини вихідного тексту за допомогою директив #define, #ifdef та #ifndef. За допомогою директиви #define можна визначити нову константу препроцесора в будь-якому місці. В іншому місці можна перевірити цей факт за допомогою директив #ifdef або #ifndef. Наприклад:

#define NEW_NAME
...
#ifdef NEW_NAME
// Включаємо цей код
#else
// Не включаємо цього коду
#endif

Якщо після константи у визначенні #define розташувати послідовність символів, препроцесор здійснюватиме заміну визначеної константи цією послідовністю. Наприклад, якщо можна замінити слово BEGIN фігурною дужкою, що розкривається, а слово END фігурною дужкою, що закривається, можна створювати код, трохи схожий на код мови Паскаль:

#include <stdio.h>
#define BEGIN {
#define END }

int main(void)

BEGIN
    printf("Hello, Pascal!");
    return 0;   
END

Звичайно, такі директиви в реальному програмуванні не мають сенсу.

У перших версіях мови C не було можливості створювати константи за допомогою ключового слова const. Константи створювались за допомогою директиви #define. Так, наприклад, можна визначити константу π:

#include <stdio.h>
#define PI 3.14159265

int main(void)
{
    double x = PI;
    printf("%f", x);
    return 0;
}

Недоліком таких констант є відсутність можливості контролю типів.

У мові C відсутні функції-підстановки (inline). Замість них можна застосовувати константи препроцесора з круглими дужками. Наприклад

#include <stdio.h>
#define MIN(A, B) A < B ? A : B

int main(void)
{
    int i = 3, k = 4;
    printf("%d\n", MIN(i, k)); // 3
    double x = 4.5, y = 3.5;
    printf("%f\n", MIN(x, y)); // 3.500000
    return 0;
}

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

#include <stdio.h>
#define SQUARE(X) X * X

int main(void)
{
    int i = 3;
    printf("%d\n", SQUARE(i)); // 9
    printf("%f\n", SQUARE(1.5)); // 2.250000
    return 0;
}

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

#include <stdio.h>
#define SQUARE(X) X * X

int main(void)
{
    int x = 3;
    printf("%d\n", SQUARE(x + 2)); // 11
    return 0;
}

Такий результат пов'язаний з тим, що замість аргументу здійснюється підстановка без дужок: 3 + 2 * 3 + 2. Для того, щоб макрос коректно працював, слід всі параметри брати в дужки:

#include <stdio.h>
#define SQUARE(X) (X) * (X)

int main(void)
{
    int x = 3;
    printf("%d\n", SQUARE(x + 2)); // 25
    return 0;
}

2.5.4 Робота з динамічною пам'яттю засобами мови C

Базова робота зі вказівниками у мові C аналогічна C++. Найбільш істотні відмінності стосуються використання динамічної пам'яті. У мові C немає операцій new і delete. Замість цих операцій застосовують функції malloc() і free().

Наприклад, так можна створити масив цілих в динамічній пам'яті, здійснити певні дії над масивом і звільнити пам'ять:

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int n;
    int* p;
    scanf("%d", &n);
    p = (int*)malloc(n * sizeof(int));
    for (int i = 0; i < n; i++)
    {
        p[i] = i + 1;
    }
    for (int i = 0; i < n; i++)
    {
        printf("%d\t", p[i]);
    }
    free(p);
}

Аналогічно можна створити двовимірний масив (масив масивів). Звільнювати пам'ять теж слід рядок за рядком:

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int m, n;
    int** arr;
    printf("Enter m and n\n");
    scanf("%d", &m);
    scanf("%d", &n);
    arr = (int**)malloc(m * sizeof(int*));
    for (int i = 0; i < m; i++)
        arr[i] = (int*)malloc(n * sizeof(int));
    for (int i = 0; i < m; i++)
        for (int j = 0; j < n; j++)
            arr[i][j] = i + j;
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++)
            printf("%d\t", arr[i][j]);
        printf("\n");
    }
    for (int i = 0; i < m; i++)
        free(arr[i]);
    free(arr);
}

Передача масивів у функції через вказівники аналогічна мові C++.

2.6 Робота з текстовими файлами

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

Класи файлових потоків std::ifstream і std::ofstream визначені в заголовному файлі fstream. Файловий потік повинен бути підключений до файлу, перш ніж він може бути використаний. У наведеному нижче прикладі програма зчитує ціле значення з файлу "data.txt" у змінну k. Це значення буде записано в інший файл:

#include <iostream>
#include <fstream>

int main()
{
    int k;
    std::ifstream inFile("data.txt");
    inFile >> k;
    std::ofstream outFile("result.txt");
    outFile << k;
}

Текстовий файл data.txt зі значенням k необхідно створити заздалегідь. Якщо проєкт створено в середовищі Visual Studio, файл слід створити в теці проєкту. Туди буде також записано файл result.txt.

Якщо необхідно перевірити можливість читання даних з файлу, можна перевірити результат методу eof(). Можна також перетворити об'єкти потоку в цілі числа і в такий спосіб перевірити стан потоку. Другий підхід є більш ефективним. У наведеному нижче прикладі з файлу читаються дійсні числа (до кінця файлу) і знаходиться сума цих чисел. Якщо ціле значення, до якого можна привести змінну-потік, дорівнює нулю, це означає, що в файлі більше немає даних. У прикладі 3.6 показано використання цього підходу.

Передавати потоки у функції можна лише за посиланням.

Інший підхід – це використання вказівника на файл (FILE *) і пари функцій fscanf / fprintf. Функція fgets() дозволяє читати рядок із файлу. Попередній приклад можна реалізувати за допомогою функцій мови C:

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>

int main()  
{
    FILE *f_in, *f_out;
    f_in = fopen("data.txt", "r");
    int k;
    fscanf(f_in, "%d", &k);
    fclose(f_in);
    f_out = fopen("result.txt", "w");
    fprintf(f_out, "%d", k);
    fclose(f_out);
    return 0;
}

Підхід мови C є небезпечним з точки зору типів.

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

3.1 Максимальний елемент

Припустимо, необхідно створити програму, в якій здійснюється читання з текстового файлу кількості елементів масиву дійсних чисел, а також самі елементи. Масив створюється в динамічній пам'яті. Далі в масиві знаходиться максимальний елемент і виводиться на екран функцією printf().

Спочатку в теці проєкту слід створити текстовий файл (in.txt), наприклад, з таким вмістом:

5
0.1 1.6 0.2 0.8 0.4

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

У програмі замість індексів ми будемо використовувати вказівники та адресну арифметику. Програма буде такою:

#include <iostream>
#include <fstream>
#include <cstdio>

using std::ifstream; 
using std::printf;

int main()
{
    ifstream in("in.txt"); // відкриваємо файл
    int n;
    in >> n; // читаємо з файлу кількість елементів масиву
    double* a = new double[n];
    for (double* p = a; p != a + n; p++)
    {
        in >> *p; // читаємо елементи
    }
    double* max = a; // спочатку max вказує на нульовий елемент
    for (double* p = a + 1; p != a + n; p++)
    {
        if (*p > *max)
        {
            max = p; // змінюємо значення вказівника
        }
    }
    printf("max item: %5.2f", *max);
    delete[] a;
    return 0;
}

3.2 Знаходження суми матриць

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

#include <cstdio>
#include <iostream>
using namespace std;

// Повертає дійсне значення в діапазоні від 0 до 100
double nextRandom()
{
    return (rand() % 10000) / 100.;
}

// Здійснює читання з клавіатури m і n
// з перевіркою допустимості
bool readData(int& m, int& n)
{
    cout << "Enter m and n: ";
    cin >> m >> n;
    if (m <= 0 || n <= 0)
    {
        cout << "Wrong values";
        return false;
    }
    return true;
}

// Створює двовимірний масив необхідних розмірів 
// та повертає вказівник на нього
double** createMatrix(int m, int n)
{
    double** matrix = new double* [m];
    for (int i = 0; i < m; i++)
    {
        matrix[i] = new double[n];
    }
    return matrix;
}

// Заповнює масив випадковими значеннями
void fillMatrix(double** matrix, int m, int n)
{
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++)
        {
            matrix[i][j] = nextRandom();
        }
    }
}

// Виводить елементи масиву на екран
void showMatrix(double** matrix, int m, int n)
{
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++)
        {
            cout << matrix[i][j] << "\t";
        }
        cout << endl;
    }
    cout << endl;
}

// Знаходить та повертає суму матриць
double** sumOfMatrices(double** a, double** b, int m, int n)
{
    double** c = createMatrix(m, n);
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++)
        {
            c[i][j] = a[i][j] + b[i][j];
        }
    }
    return c;
}

// Видаляє масив з динамічної пам'яті
void deleteMatrix(double** matrix, int m)
{
    for (int i = 0; i < m; i++)
    {
        delete[] matrix[i];
    }
    delete[] matrix;
}

int main()
{
    int m, n;
    if (readData(m, n))
    {
        double **a = createMatrix(m, n);
        fillMatrix(a, m, n);
        showMatrix(a, m, n);
        double** b = createMatrix(m, n);
        fillMatrix(b, m, n);
        showMatrix(b, m, n);
        double **c = sumOfMatrices(a, b, m, n);
        showMatrix(c, m, n);
        deleteMatrix(a, m);
        deleteMatrix(b, m);
        deleteMatrix(c, m);
        return 0;
    }
    return 1;
}

Як видно з коду, циклі, побудовані на діапазоні, не можна використовувати.

3.3 Сума добутків

У наведеній нижче програмі двовимірний масив дійсних чисел заповнюється випадковими значеннями в діапазоні від 0 до 3 і здійснюється знаходження суми добутків його рядків. Кількість рядків (m) і стовпців (n) користувач уводить з клавіатури, для чого застосовано функцію scanf().

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

#define _CRT_SECURE_NO_WARNINGS

#include <cstdlib>
#include <cstdio>
#include <ctime>

using std::srand;
using std::time;
using std::rand;
using std::scanf;
using std::printf;

// Функція повертає псевдовипадкове число в діапазоні
// від 0 (включаючи) до 1 (не включаючи).
// RAND_MAX - ціла константа, максимальне значення, 
// яке повертає rand()
double doubleRand() {
    // Додаємо 1.0 для того, щоб включити 0 і не включити 1:
    return rand() / (RAND_MAX + 1.0);
}

int main()
{
    srand(time(0));
    int m, n;
    // Введення даних і перевірка кількості прочитаних байтів:
    if ((scanf("%d %d", &m, &n)) < 2)
    {
        return -1;
    }
    // Створення і заповнення масиву:
    double** a = new double*[m];
    for (int i = 0; i < m; i++)
    {
        a[i] = new double[n];
        for (int j = 0; j < n; j++)
        {
            a[i][j] = doubleRand() * 3;
        }
    }
    // Виведення елементів масиву рядок за рядком:
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++)
        {
            printf("a[%d][%d] = %f\t", i, j, a[i][j]);
        }
        printf("\n");
    }
    // Обчислення суми добутків:
    double sum = 0;
    for (int i = 0; i < m; i++)
    {
        double product = 1;
        for (int j = 0; j < n; j++)
        {
            product *= a[i][j];
        }
        sum += product;
    }
    printf("sum: %f", sum);
    // Звільнення пам'яті:
    for (int i = 0; i < m; i++)
    {
        delete[] a[i];
    }
    delete[] a;
    return 0;
}

3.4 Пошук від'ємних елементів

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

Сирцевий код функції main() буде таким:

const int n = 7;
double a[n] = { 10, 11, -3, -2, 0, -1, 4 };
int count = 0;
int i;
for (i = 0; i < n; i++)
{
    if (a[i] < 0)
    {
        count++;
    }
}
double *b = new double [count]; // b зберігатиме від'ємні елементи
int j = 0;                      // індекс у масиві b
for (i = 0; i < n; i++)
{
    if (a[i] < 0)
    {
        b[j] = a[i];
        j++;                    // наступний індекс
    }
}
for (j = 0; j < count; j++)
{
    cout <<  b[j] << ' ';       // виведення
}
delete [] b;                    // звільнення пам'яті

3.5 Видалення цифр з рядка

У наведеній нижче програмі здійснюється читання рядка з клавіатури до натискання користувачем клавіші Enter. У новий рядок записуються всі символи окрім цифр

#include <iostream>
#include <cstring>

using namespace std;

int main()
{
    char str[80], result[80];
    cin.getline(str, 80);
    int i, k;
    // Потрібно скопіювати strlen(str) + 1 символ, включаючи '\0':
    for (i = 0, k = 0; i <= strlen(str); i++)
    {
        if (str[i] < '0' || str[i] > '9')
        {
            result[k] = str[i];
            k++;
        }
    }
    cout << result;
    return 0;
}

Як видно з програмного коду, залишаються всі символи, коди яких не знаходяться в діапазоні кодів десяткових цифр. У кодовій таблиці ці коди розташовані послідовно.

Для читання рядка до кінця застосовано функцію getline() об'єкта cin. Для читання рядків можна також використовувати операцію >>:

cin >> str;

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

3.6 Сума

Припустимо, що ми підготували текстовий файл з ім'ям "Numbers.txt", який містить цілі значення, розділені пробілами, символами табуляції або символи нового рядка. Наприклад

1 22 -3 11	-9 144 
1000
1 1 1 -3 5	4
3 55	2

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

Файл з вихідними даними повинен бути поміщений у робочий каталог проекту. Сирцевий код програми може бути таким:

#include <iostream>
#include <fstream>

int main()
{
    std::ifstream in("Numbers.txt");
    int x, sum = 0;
    while (in >> x) // читання успішне
    {
        sum += x;
    }
    std::cout << sum;
    return 0;
}

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

  1. Написати програму, яка обчислює суму додатних елементів масиву дійсних чисел. Замість індексів використати вказівники та адресну арифметику.
  2. Прочитати рядок, замінити пропуски на символи підкреслення та вивести результат.
  3. Написати програму, яка читає цілі до значення 0 і обчислює добуток цих чисел без останнього нульового значення.
  4. Написати програму, яка читає цілі до кінця файлу й обчислює добуток ненульових значень.
  5. Написати програму, яка визначає масив чисел і записує в текстовий файл суми елементів з непарними індексами.

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

  1. Що таке вказівник?
  2. Як отримати адресу змінної?
  3. Як здійснюється розіменування?
  4. Які є варіанти використання вказівників?
  5. У чому різниця між постійним указівником і вказівником на постійний об'єкт?
  6. Як отримати адресу масиву?
  7. Який є взаємозв'язок між масивами і вказівниками?
  8. Що таке адресна арифметика?
  9. Що є результатом оператора "мінус", застосованого до двох указівників?
  10. Які області комп'ютерної пам'яті використовують для розташування змінних?
  11. Що таке динамічна пам'ять?
  12. Як розташувати змінну в динамічній пам'яті?
  13. Чому необхідно видаляти непотрібні змінні з динамічної пам'яті?
  14. Як розташувати двовимірний масив у динамічній пам'яті?
  15. У чому є специфіка символьних масивів?
  16. Що таке рядок, який закінчується нулем (null-terminated string)?
  17. Які функції мови C можна використовувати для виведення і введення?
  18. Які є недоліки використання функцій printf() і scanf()?
  19. Чому явне закриття файлів у C++ не є обов'язковим?
  20. Як перевірити, що ми дісталися кінця файлу?

 

up