Модульные программы. Ввод/вывод в 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 файле написать реализацию для метода, перед его названием необходимо написать ***“<имя класса="">::”***.имя>
Этот подход к созданию классов имеет несколько преимуществ:
-
при анализе назначения класса достаточно посмотреть только заголовочный (.hpp) файл, т.к. по названиям методов уже будет понятна его суть;
-
при написании кода можно временно абстрагироваться от конкретных реализаций: в заголовочном (.hpp) файле можно написать определение класса и затем использовать его в других модулях, а реализацию написать позже.
Для того, чтобы использовать разработанный класс, его необходимо подключить директивой 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>.
Открыть файл можно двумя способами:
-
передав при вызове конструктора класса путь к файлу;
-
вызвав метод open, передав в качестве аргумента путь к файлу.
Стоит понимать, что если на запись открывается уже существующий файл, всё его содержимое будет перезаписано. Если он не существует, то будет создан.
В качестве пути к файлу можно написать как относительный, так и абсолютный.
Относительный путь — путь, составленный относительно папки, в которой выполняется программа. Например, если 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, стоит явно указывать режимы работы. Его особенность заключается в том, что файл может быть открыт одновременно для записи и чтения.
Иногда может быть полезно начать чтение/запись файла не с начала или конца, а с конкретной позиции. Для смещения текущего указателя, с которого начинается чтение или запись, используются методы:
-
seekg(<offset>, <origin>) - используется для смещения указателя чтения на <offset> символов, относительно положения <origin>;
-
seekp(<offset>, <origin>) - используется для смещения указателя записи на <offset> символов, относительно положения <origin>.
Положение <origin> может быть одним из:
-
ios::beg - начало файла;
-
ios::cur - текущее положение;
-
ios::end - конец файла.
Например, для смещения указателя чтения на 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, …), необходимо перегрузить операторы «, ». Для этого надо соблюдать правила:
-
операторы ввода (») и вывода («) перегружаются как внешние дружественные функции для пользовательских классов;
-
перегрузка оператора вывода « обычно принимает первым параметром ссылку на поток вывода std::ostream&, а вторым — константную ссылку на объект класса. Функция должна возвращать ссылку на поток, чтобы поддержать цепочку вызовов;
-
перегрузка оператора ввода » принимает первым параметром ссылку на поток ввода std::istream&, а вторым — ссылку на объект класса (чтобы изменять его состояние);
-
операторы реализуются вне класса, но объявляются дружественными (friend), чтобы иметь доступ к приватным членам.
Рассмотрим пример перегрузки операторов «, » для класса 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) и контроллеры. Последние отвечают за обработку входящих запросов от пользователей или других систем, управление потоком данных и координацию действий между пользовательским интерфейсом и бизнес-логикой приложения.