Модульные программы. Ввод/вывод в C++. Библиотеки ввода/вывода

Модули в C++

Модули — это логически связанный набор исходных (.cpp) и заголовочных (.hpp) файлов, в котором заголовочные файлы содержат объявления (интерфейс) классов, функций, констант и других сущностей, а исходные файлы содержат их реализации (определения). Такой модуль служит единицей организации кода, позволяя разделять программу на независимые части, которые компилируются отдельно и используются совместно через интерфейс (объявления функций, методов), представленный заголовочными файлами.

Наиболее удобно использовать разделение на .cpp и .hpp файлы при разработке классов. Например, реализация класса Account с разделением выглядит следующим образом:

Account.hpp

#include <string>

class Account {
private:
    std::string owner;
    double balance;

public:
    Account(const std::string& ownerName, double initialBalance);
    void deposit(double amount);
    void withdraw(double amount);
    void displayBalance();
};

Account.cpp

#include "Account.hpp"
#include <iostream>

Account::Account(const std::string& ownerName, double initialBalance) {
    owner = ownerName;
    balance = initialBalance;
}

void Account::deposit(double amount) {
    balance += amount;
}

void Account::withdraw(double amount) {
    if (amount <= balance) {
        balance -= amount;
    }
}

void Account::displayBalance() {
    std::cout << owner << "'s account balance: " << balance << "\n";
}

Таким образом, в файле Account.hpp объявляется класс, все его поля и методы, а в Account.cpp пишется их реализация.

Для того, чтобы в .cpp файле написать реализацию для метода, перед его названием необходимо написать ***“<имя класса="">::”***.

Этот подход к созданию классов имеет несколько преимуществ:

Для того, чтобы использовать разработанный класс, его необходимо подключить директивой include.

main.cpp

#include "Account.hpp"

int main() {
    Account acc("Alice", 1000);
    acc.deposit(500);
    acc.withdraw(200);
    acc.displayBalance();
    return 0;
}

При подключении модулей через директиву include может возникнуть ситуация, что один и тот же файл подключается много раз. В этом случае возникнет ошибка при компиляции. Для того, чтобы её избежать, стоит использовать директиву #pragma once. При её использовании каждый модуль подключается только один раз.

При отсутствии модульной структуры программы могут возникнуть следующие проблемы:

Стандартные потоки ввода-вывода

В языке C++ для ввода/вывода данных реализованы специальные классы-потоки: istream и ostream. В стандартной библиотеке заранее созданы объекты этих классов: cin, cout, clog, cerr.

Класс-поток Имя объекта Назначение
istream cin Используется для ввода данных
ostream cout Используется для (буферизированного) вывода информации
ostream clog Используется для (буферизированного) вывода служебной информации
ostream cerr Используется для (небуферизированного) вывода ошибок во время выполнения программы

Буферизация — важная особенность потоков ввода-вывода. Поток cout и clog буферезированы. Это означает, что данные сначала накапливаются в памяти, а потом выводятся сразу группой. Это снижает количество обращений к устройству вывода и повышает производительность.

Поток cerr при этом не буферизирован, так как ошибки должны быть показаны максимально быстро для оперативного реагирования.

Для использования поток необходимо подключить директивой include соответствующий либо один из классов ostream/istream, либо сразу оба, прописав #include <iostream>.

Ниже представлен пример использования стандартных потоков ввода/вывода:

Использование объектов ввода/вывода

#include <iostream>

int main() {
    int a, b;

    // Ввод переменных a, b при помощи cin.
    std::cin >> a;
    std::cin >> b;

    // Вывод служебной информации о том, что начинается процесс деления чисел.
    std::clog << "Отладка программы. Начинается процесс деления числа a на b.\n";

    if (b == 0) {
        std::cerr << "Ошибка деления. Делитель оказался равен 0. Деление не может быть выполнено.\n";
    } else {
        double division_result = (double)a / b;
        
        std::cout << "Результат деления числа a на b равен: " << division_result << "\n";
    }

    return 0;
}

Стоит понимать, что не каждую переменную/объект можно ввести/вывести, используя потоки ввода/вывода. Эти операторы поддерживаются для большинства встроенных типов (int, double, float, …), но по-умолчанию их нельзя использовать для пользовательских типов. Далее будет описано, как реализовать поддержку использования объектов собственных классов в потоках cin, cout, clog, cerr.

При использовании буферизированных потоков может возникнуть необходимость вывести сообщение сразу. Для этого необходимо сбросить текущий буфер. Для этого могут использоваться операторы endl или flush. Оба оператора сбрасывают кеш и вызываются при использовании потоков вывода. Отличие заключается в том, что при использовании endl вывод следующих сообщений будет производиться со следующей строки, а flush не сделает переноса строки.

Использование операторов сброса буфера вывода

#include <iostream>

int main() {
    std::cout << "Всем привет," << std::endl;

    std::cout << "Желаю хорошего дня" << std::flush;
    std::cout << " и прекрасного настроения!" << std::endl;

    // В консоль будет выведено сообщение:
    // Всем привет,
    // Желаю хорошего дня и прекрасного настроения!

    return 0;
}

При использовании объектов ввода/вывода необходимо указывать их пространство имён: std. Однако, это может быть неудобно при многократном вводе/выводе данных. Чтобы не указывать явно пространство имён, его можно подключить заранее командой using namespace std;

Подключение пространства имён std

#include <iostream>

using namespace std;

int main() {
    cout << "Всем привет," << endl;

    cout << "Желаю хорошего дня" << flush;
    cout << " и прекрасного настроения!" << endl;

    // В консоль будет выведено сообщение:
    // Всем привет,
    // Желаю хорошего дня и прекрасного настроения!

    return 0;
}

Файловые потоки

Для работы с текстовыми файлами в C++ предусмотрено 3 класса: ifstream, ofstream, fstream.

Название класса Назначение
ifstream Используется для чтения данных из файла
ofstream Используется для записи данных в файл
fstream Используется одновременно для чтения и записи данных в файл

Классы принадлежат пространству имён std, поэтому для их использования необходимо либо заранее прописать using namespace std, либо перед названием класса писать std::.

Алгоритм работы с файлами общий для всех классов. Необходимо:

Для использования перечисленных классов, необходимо прописать директиру #include <fstream>.

Открыть файл можно двумя способами:

Стоит понимать, что если на запись открывается уже существующий файл, всё его содержимое будет перезаписано. Если он не существует, то будет создан.

В качестве пути к файлу можно написать как относительный, так и абсолютный.

Относительный путь — путь, составленный относительно папки, в которой выполняется программа. Например, если exe файл расположен в папке “C:\Student\MineFolder”, то при указании относительного пути “example.txt”, файл будет искаться в папке “C:\Student\MineFolder”.

Абсолютный путь — полный путь к файлу, включающий в себя том (диск) и все папки для достижения файла. Например, “C:\Student\MineFolder\example.txt”.

Для определения, был ли открыт файл для записи/чтения предусмотрен метод is_open(), возвращающий true при успешном открытии файла и false - в противном случае.

Для того, чтобы закрыть файл, необходимо использовать метод close().

Рассмотрим примеры использования классов ofstream и ifstream для записи и чтения данных. Ожидается, что содержимое файла “example.txt” будет следующий текст:

Привет, файл!
Тут приводится содержимое файла.

Открытие файла для записи (ofstream)

#include <fstream>
#include <iostream>

using namespace std;

int main() {
    // Открываем файл для записи.
    ofstream ofs("example.txt");

    if (!ofs.is_open()) {
        cout << "Не удалось открыть файл для записи\n";
        return 1;
    }

    ofs << "Привет, файл!" << endl;
    ofs << "Тут приводится содержимое файла." << endl;

    ofs.close();

    return 0;
}

Открытие файла для чтения (ifstream)

#include <fstream>
#include <iostream>
#include <string>

using namespace std;

int main() {
    // Открываем файл для чтения.
    ifstream ifs("example.txt");

    if (!ifs.is_open()) {
        cout << "Не удалось открыть файл для чтения" << endl;
        return 1;
    }
    
    string line;
    
    while (getline(ifs, line)) {
        cout << line << endl;
    }

    ifs.close();

    return 0;
}

Помимо указания пути к файлу, при вызове метода open() или конструктора, можно также указать режим открытия. Существует несколько режимов:

Режим Назначение
ios::in Файл открывается для ввода (чтения). Может быть установлен только для объекта ifstream или fstream
ios::out Файл открывается для вывода (записи). При этом старые данные удаляются. Может быть установлен только для объекта ofstream или fstream
ios::app Файл открывается для дозаписи. Старые данные не удаляются. Дозапись осуществляется в конец файла
ios::ate После открытия файла перемещает указатель в конец файла
ios::trunc Файл усекается при открытии. Может быть установлен, если также установлен режим out
ios::binary Файл открывается в бинарном режиме

Пример открытия файла для дозаписи в конец:

Открытие файла для дозаписи в конец

std::ofstream out;
out.open("hello.txt", std::ios::app);
В случае, если необходимо указать несколько режимов открытия сразу, нужно их перечислить через знак ** **.

Открытие файла с перечислением нескольких режимов

std::ofstream out;
out.open("hello.txt", std::ios::out | std::ios::trunc);

При использовании класса fstream, стоит явно указывать режимы работы. Его особенность заключается в том, что файл может быть открыт одновременно для записи и чтения.

Иногда может быть полезно начать чтение/запись файла не с начала или конца, а с конкретной позиции. Для смещения текущего указателя, с которого начинается чтение или запись, используются методы:

Положение <origin> может быть одним из:

Например, для смещения указателя чтения на 5 символов от текущей позиции, необходимо использовать вызов:

Смещение указателя чтения на 5 символов относительно текущей позиции

ifstream ifs("example.txt");
...
ifs.seekg(5, ios::cur);

Если не указывать явно, то считается, что смещение указывается относительно начала файла.

Ниже представлен пример использования класса fstream одновременно для чтения и записи.

Использование класса fstream для чтения и записи

#include <fstream>
#include <iostream>
#include <string>

using namespace std;

int main() {
    // Открываем файл для чтения и записи, очищая его содержимое (trunc).
    fstream file("example.txt", ios::in | ios::out | ios::trunc);

    if (!file.is_open()) {
        cout << "Не удалось открыть файл" << endl;
        return 1;
    }

    // Запись текста в файл.
    file << "Привет, это пример использования fstream!" << endl;

    // Перемещаем указатель чтения в начало файла.
    file.seekg(0);

    // Чтение и вывод содержимого файла построчно.
    string line;
    while (getline(file, line)) {
        cout << line << "" << endl;
    }

    file.close();
    
    return 0;
}

Ввод-вывод объектов пользовательских типов

Для того, чтобы можно было использовать объекты пользовательских типов для вывода/чтения из консоли (cout, clog, cerr, cin) или для записи/чтения из файла также, как это делается со встроенными типами (int, double, float, ), необходимо перегрузить операторы «, ». Для этого надо соблюдать правила:

Рассмотрим пример перегрузки операторов «, » для класса Person:

Person.hpp

#include <iostream>
#include <string>

class Person {
private:
    std::string name;
    int age;

public:
    Person();
    Person(const std::string& n, int a);

    // Объявления дружественных функций для ввода-вывода
    friend std::ostream& operator<<(std::ostream& os, const Person& p);
    friend std::istream& operator>>(std::istream& is, Person& p);
};

Person.cpp

#include "Person.hpp"

Person::Person() : name(""), age(0) {}

Person::Person(const std::string& n, int a) : name(n), age(a) {}

std::ostream& operator<<(std::ostream& os, const Person& p) {
    os << "Имя: " << p.name << ", Возраст: " << p.age;
    return os;
}

std::istream& operator>>(std::istream& is, Person& p) {
    std::cout << "Введите имя: ";
    is >> p.name;
    std::cout << "Введите возраст: ";
    is >> p.age;
    return is;
}

Теперь данный объекты данного класса можно спокойно использовать при вызове cout, clog, cerr, cin: Вызов перегрузки операторов ввода/вывода в консоль

#include <iostream>
#include "Person.hpp"

int main() {
    Person p;
    std::cin >> p;
    std::cout << p << std::endl;
    return 0;
}

В данном примере при реализации перегрузки оператора » в консоль выводится сообщение о необходимости ввода имени и возраста. Если планируется использовать класс для работы с файлами, то так делать не рекомендуется. В этом случае:

Пример перегрузки операторов, учитывающей специфику работы с файлами:

Перегрузка операторов ввода/вывода для файлов

Person::Person() : name(""), age(0) {}

Person::Person(const std::string& n, int a) : name(n), age(a) {}

std::ostream& operator<<(std::ostream& os, const Person& p) {
    os << " " << p.name << " " << p.age;
    return os;
}

std::istream& operator>>(std::istream& is, Person& p) {
    is >> p.name >> p.age;
    return is;
}

Слоённая архитектура программ

Для организации понятной структуры программы, рекомендуется использовать слоённую архитектуру.

Слоистая архитектура предлагает деление программного обеспечения на отдельные уровни (слои), каждый из которых выполняет строго определенный набор функций. Главная цель такого подхода - обеспечить независимость и модульность компонентов, а также четкую организацию кода для оптимизации разработки, масштабирования и поддержки приложений.

Такая архитектура предполагает наличие трёх основных слоёв в приложении:

Слои в приложении

Интеграционный слой. Задача этого слоя - взаимодействие с внешними сервисами, системами и базами данных. Он абстрагирует работу с данными, позволяя бизнес-слою не зависеть от конкретных механизмов хранения или внешних сервисов. Только на этом слое должны происходить операции ввода/вывода в консоль, файл или базу данных.

Бизнес-слой. Бизнес-слой - это связующее звено между презентационным слоем и интеграционным слоем. Среди его задач - обработка бизнес-правил, запросов и операций, связанных с данными, полученными от пользователя. На этом уровне:

Презентационный слой. Слой, который взаимодействует непосредственно с пользователем, называется презентационным. Он отвечает за представление информации и адаптируется под различные платформы, например, веб и мобильные приложения, без изменения бизнес-логики. На этом слое, среди прочего, располагаются пользовательский интерфейс (UI) и контроллеры. Последние отвечают за обработку входящих запросов от пользователей или других систем, управление потоком данных и координацию действий между пользовательским интерфейсом и бизнес-логикой приложения.