Настоятельно рекомендуется после выполнения каждого пункта данной работы делать коммиты.
Написать функции для печати отдельных байт и блока данных data
размером size
байтов в шестнадцатеричном и в двоичном представлении:
void print_in_hex(uint8_t byte);
void print_in_hex(const void* data, size_t size);
void print_in_binary(uint8_t byte);
void print_in_binary(const void* data, size_t size);
Для удобства чтения рекомендуется между байтами добавлять пробелы и делать перевод строки, например, после каждых 16-и байт (в print_in_hex()
) или после каждых четырех байт (в print_in_binary()
).
Пользователь вводит первый операнд, затем оператор (символ &
, |
или ^
), затем второй операнд. Программа выполняет указанное действие над операндами и печатает результат в шестнадцатеричном и двоичном виде. Операнды — двухбайтовые беззнаковые целые числа (uint16_t
).
Пример работы:
Ввод: 1025 & 127
Вывод на экран: 01 04 & 7F 00 = 01 00
00000001 00000100 & 01111111 00000000 = 00000001 00000000
Определить структуру Student
, описывающую студента атрибутами:
'\0'
).Student
, описывающую старосту группы (для старосты — нулевой указатель).Указание. Поле пола из одного бита можно определить так:
Объявить и заполнить массив из трех структур Student
, описывающий двух студентов одной группы и их старосту.
Напечатать, занести в отчет и письменно пояснить:
Указание. Смещение поля field
структуры типа type
от начала любого её экземпляра можно определить макросом offsetof(type, field)
.
Данная часть лабораторной работы выполняется отдельно от предыдущей части, в новом проекте.
В учебных целях текст нужно представлять только строками C, размещаемыми в динамической памяти или на стеке.
Запросить у пользователя имя файла, сохранив его в массиве символов, размещенном на стеке (не в динамической памяти).
Проверить, используя функции стандартной библиотеки C++ для работы со строками C, что введенное имя файла корректно (в Windows):
*
, "
, <
, >
, ?
или |
;\
).*.txt
(в любом регистре).Указание. Задачи решаются стандартными функциями isalpha()
, strchr()
, strrchr()
, strncmp()
, tolower()
.
Если введенное имя файла не имеет расширения, добавить расширение .txt
.
Загрузить содержимое текстового файла в память целиком:
ifstream
или fopen()
для доступа к файлу.seekg()
и tellg()
либо функции fseek()
и ftell()
для определения размера файла, переместившись в его конец и получив текущее положение в файле;read()
или функцией fread()
.Запросить у пользователя строку, поместив её в массив на стеке.
Подсчитать и вывести число вхождений введенной строки в текст файла.
Освободить все выделенные в процессе решения блоки памяти.
print_in_*()
Разобьем задачу на более простые части.
Начнем с print_in_hex()
. Байт — это 8 бит, то есть две цифры в шестнадцатеричной системе. Чтобы напечатать байт, нужно напечатать цифру, соответствующую его старшей и младшей половине (они называются nibble). Любой блок данных (по адресу в нетипизированном указателе void*
) — это массив байт; нужно только указать компилятору рассмотреть void*
как uint8_t*
. Очевидно, чтобы напечатать массив байт, нужно напечатать каждый байт в цикле.
Перевод байта в двоичную запись можно делать целиком, дробить байт нет смысла. Печать массива байт в двоичном виде по сути не отличается от печати их в шестнадцатеричной системе счисления.
Итак, элементарные задачи:
void*
в uint8_t*
.size
элементов массива по адресу в uint8_t*
(hex).size
элементов массива по адресу в uint8_t*
(binary).Напишем вспомогательную функцию, которая будет представлять значение от 0 до 15 в шестнадцатеричном виде. Что она принимает? Целое число от 0 до 15, для этого достаточно uint8_t
. Что она возвращает? Можно сразу печатать результат, тогда не нужно возвращать ничего (void
). Но вспомним, что функции желательно делать максимально пригодными для повторного использования, и вовсе не всегда нужно печатать nibble не экране. Поэтому лучше возвращать символ для nibble, то есть char
. Итого: char nibble_to_hex(uint8_t i);
.
Как реализовать nibble_to_hex()
? Очевидный вариант — через switch
на 16 вариантов. Есть и более лаконичный вариант: заведем массив цифр char digits[] = "0123456789abcdef";
и будем для значения i
возвращать digits[i]
. На практике популярен еще один вариант - через коды символов. Вспомним, что символы в памяти хранятся как их коды, например, коды цифр от '0'
до '9'
— от 48 до 57, а коды букв от 'a'
до 'f'
— от 97 до 102. Таким образом, если i
меньше 10, можно прибавить i
к '0'
и получить соответствующую цифру; если i
больше, нужно прибавить к 'a'
столько, на сколько i
больше 10 (то есть для 10 — 0, для 11 — 1 и т. д.).
Важный момент — самопроверка. Чтобы проверить работу nibble_to_hex()
, добавим в программу функцию-тест, вызываемую в начале main()
, из 16 строк:
assert(nibble_to_hex(0x0) == '0');
assert(nibble_to_hex(0x1) == '1');
// ...
assert(nibble_to_hex(0xf) == 'f');
Еще один вопрос — реакция nibble_to_hex()
на некорректные значения аргумента. Можно решить его путем защитного программирования: добавить в начало функции
assert(0x0 <= i && i <= 0xf);
Задача сводится к тому, чтобы из восьми бит четыре младших оставить такими, как есть, а четыре старших обнулить. Типовое решение — наложить битовую маску. Битовая маска следующая: 0b00001111
, или 0x0f
, — в ней единицы стоят в тех позициях, биты в которых нужно извлечь. Логическое «И» (&
) бита x
с нулем дает 0, а с единицей - x
, то есть byte & mask
даст искомый младший nibble. Решение для математиков — взять остаток от деления байта на 32 (0b00010000
).
Можно разбить эту задачу еще на две: выделение старших разрядов байта и их перемещение (сдвиг) на позиции младших разрядов. Какая маска подойдет для выделения? Очевидно, 0b11110000
(0xf0
). Сдвиг вправо на 4 разряда делается оператором >>
: byte >> 4
. По стандарту C++, старшие биты результата будут равны 0, поэтому на самом деле выделять старшие биты не нужно. Сдвиг вправо на 4 позиции математически равносилен делению на 2⁴, но при работе с битами сдвиг лучше выражает суть дела.
Запишем в коде все предыдущие рассуждения:
Для самопроверки следует попробовать напечатать байты 0x0
, 0xab
, 0xff
.
void*
в uint8_t*
и напечатать массив этих байтЗаключим преобразование типов в функцию. В реальной программе это было бы излишне, но функция — это ведь еще и помощь программисту в структурировании программы, и раз так удобнее рассуждать, то так и сделаем.
Здесь важны ключевые слова const
. Они означают, что данные по адресу, хранимому в указателе, не могут быть изменены через этот указатель.
Считая, что она реализована, можно записать печать массива сразу:
void
print_in_hex(const void* data, size_t size) {
const uint8_t* bytes = as_bytes(data);
for (size_t i = 0; i < size; i++) {
print_in_hex(bytes[i]);
// Для удобства чтения: пробелы между байтам, по 16 байт на строку.
if ((i + 1) % 16 == 0) {
cout << '\n';
}
else {
cout << ' ';
}
}
}
Указание компилятору, что значение одного типа нужно трактовать как значение другого, называется приведением типов. Из лекций известно, что в данном случае корректна такая реализация:
Самопроверка: завести переменные типа uint8_t
, uint16_t
, uint32_t
и дать им одно и то же значение, 0x42
. Напечатать их через новую функцию и убедиться визуально, что единственным ненулевым байтом будет 0x42
в каждом случае, а всего байт будет столько, сколько ожидается. Вторым параметром следует передавать sizeof
, например:
Известен способ перевода в двоичную систему путем взятия остатков от деления на два, однако, порядок остатком получается обратным порядку бит. В программе проще проверять биты, начиная со старшего, и печатать 0
, если бит равен 0, и 1
, если бит равен 1. Для выделения бита можно воспользоваться маской: старший бит выделяется как 0b10000000
, или (0x1 << 7)
, младший — маской (0x1 << 0)
. После наложения маски с одним установленным битом в результате останется либо 0 (если соответствующий бит не установлен), либо не-ноль, если установлен. Выделим эту логику в функцию по аналогии с nibble_to_hex()
:
Сдвиги на 7, 6, …, 0 бит логично делать циклом. Итого:
void
print_in_binary(uint8_t byte) {
for (uint8_t bit = 7; bit > 0; bit--) {
cout << bit_digit(byte, bit);
}
}
Внимание: в данном цикле имеется ошибка - не выводится бит младшего разряда. Предлается устранить ошибку самостоятельно. Самопроверка: напечатать число 15 - ожидаемый вывод: 00001111
. Перевести в двоичное представление и напечатать числа из лекционного слайда про двоичные операции (исходные два числа и результаты всех действий).
Очевидно, что приведение типов не отличается от случая для шестнадцатеричной системы. Напишем и проверим по аналогии с print_in_hex()
:
void
print_in_binary(const void* data, size_t size) {
const uint8_t* bytes = as_bytes(data);
for (size_t i = 0; i < size; i++) {
print_in_binary(bytes[i]);
// Для удобства чтения: пробелы между байтами, по 4 байта на строку.
if ((i + 1) % 4 == 0) {
cout << '\n';
}
else {
cout << ' ';
}
}
}
В двоичной системе 42
будет 0b00101010
. Этот байт должен стоять первым при печати целого числа любой длины, а за ним — байты с нулями.
В отчет нужно занести код и результаты в отчет в виде текста.
Вопрос: почему 1025 (0b00000100'00000001
, 0x0401
) представлено байтами 01 04
, а не наоборот?
Ответ: потому что на x86 (Intel) порядок байт от младшего к старшему (little-endian), то есть младший байт в памяти расположен первым.
Комментарий к отчету по данному пункту ЛР.
Необходим вывод программы — все, что нужно напечатать по заданию.
В распечатанном массиве байт, которые занимает массив структур, нужно отметить, какие байты чему соответствуют (элементам массива, полям структуры).
Необходима готовность отвечать (пользуясь отчетом), что представляет собой тот или иной участок распечатанного блока памяти, и почему его содержимое именно таково (кроме действительных чисел).
Вместо пошагового выполнения ЛР рассмотрим решение двух типовых задач: ввода и обработки строки C функциями стандартной библиотеки и загрузки текста из файла в строку C. Задание на ЛР представляет собой их комбинацию.
Решим задачу: считать строку C и напечатать по отдельности слов в ней (слова разделены пробелами и знаками препинания).
Для определенности предположим, что длина строки не превышает некоторой заранее заданной, например, 255 символов. С учетом завершающего '\0'
под строку нужно 256 символов:
Ввести с строку C можно функцией fgets()
. Ознакомимся с документацией по ссылке. В документации обычно есть и примеры использования описываемых функций.
Прототип функции:
Над прототипом написано: Defined in header <cstdio>
— это значит, что для использования функции нужно включить заголовочный файл <cstdio>
.
Под прототипом написано, что делает данная функция: считывает не более count - 1
символов и записывает их в массив, на который указывает str
; чтение ведется из файлового потока stream
.
Нам необходимо считывать строку со стандартного ввода, где взять файловый поток для него? В справочнике FILE
является ссылкой на статью «C-style file input/output» («Файловый ввод-вывод средствами C»). В конце её в разделе Macros можно найти запись:
stdin expression of type FILE* associated with the input stream
То есть глобальная переменная stdin
из <cstdio>
и есть нужный поток (это упоминалось в лекциях).
Итак, вызов для чтения строки:
Заметим, что на практике, а не в учебных целях, удобнее считывать строки C++:
Если затем нужен указатель на массив считанных символов, его можно получить как text.c_str()
(менять символы в этом массиве нельзя; при необходимости есть метод text.data()
).
Чтобы напечатать слова строки по отдельности, нужно искать границы слов и печатать часть строки от начала до конца слова. Чтобы найти конец слова, нужно найти первый (от любой позиции внутри слова, в том числе от его начала) символ-разделитель. Разделители могут идти подряд. Вот пример текста:
News,from beyond the Narrow Sea. Haven't you heard?!
↑ ↑↑
нет пробела два пробела
Кроме знаков препинания, разделители включают также пробел и символы перевода строк:
Функции стандартной библиотеки для работы со строками C — в заголовочном файле <cstring>
. Из обширного списка наиболее подходящими для задачи выглядят описания:
strspn()
— определяет, сколько первых символов строки подряд относятся к множеству, заданному другой строкой;
strсspn()
— определяет, сколько первых символов строки подряд не относятся к множеству, заданному другой строкой (например, сколько символов с начала строки — не разделители слов);
Алгоритм решения:
Определить, сколько разделителей находятся в начале строки — strspn()
.
Пропустить их (сместить указатель на начало строки).
Если достигнут конец строки (начальный символ — '\0'
), закончить работу.
Найти первый разделитель от нового начала строки (или слова), то есть длину слова — strcspn()
.
Напечатать часть строки от начала слова до разделителя (это можно сделать методом cout.write()
или функцией fwrite()
). Также напечатать символ перевода строки.
Сдвинуть начало строки вперед на длину слова.
Перейти к пункту 1.
Почти каждый шаг алгоритма — всего одна строка или конструкция. Начало строки (то есть еще не разобранной части) будем хранить в переменной:
Алгоритм представляет собой цикл:
Определить, сколько разделителей находятся в начале строки:
Пропустить их:
Если достигнут конец строки, закончить работу.
Найти первый разделитель от нового начала строки:
Напечатать часть строки от начала слова до разделителя:
Также напечатать символ перевода строки:
Сдвинуть начало строки вперед на длину слова:
}
Соединив участки кода в полноценную программу, можно убедиться, что она работает правильно, выполнив в командной строке:
Вывод:
News
from
beyond
the
Narrow
Sea
Haven't
you
heard
Опишите назначение и использование операторов new
и delete
. Чем отличается синтаксис удаления массива от синтаксиса удаления одиночного значения?
Как при помощи динамических массивов организовать работу с квадратной матрицей, размер которой становится известен во время выполнения?
Что такое «рваный» массив (jagged array)? Как выделять и освобождать память под его элементы, как к ним обращаться?
Каковы особенности арифметики типов с плавающей запятой по сравнению с математически точной и сравнения их значений?
Как и почему корректно сравнивать значения с плавающей запятой и бороться с ошибками округления при операциях над такими значениями?
В чем заключается арифметика с фиксированной запятой, какие у нее преимущества и недостатки по сравнению с арифметикой типов с плавающей запятой?
Опишите действие оператора reinterpret_cast
. В каких случаях его удобно применять, и какие проблемы при этом могут возникнуть?
Что такое выравнивание данных (alignment)? Почему оно существует, зачем и как контролировать его наличие?
Как определить размер переменной в C++, и в каких случаях он отличается от размера полезных данных, связанных с переменной?
Какие побитовые операторы имеются в C++? Приведите примеры их работы.
Что такое битовые флаги и битовые маски? Как в C++ записываются числа в системах счисления, отличных от десятичной?
Каким образом можно: а) объявить целочисленную переменную размером 8, 16, 32 бита (гарантированно); б) объявить битовый массив произвольно большого размера; в) поле структуры размера, не кратного байту (например, 9 бит)?
Как в C++ объявляются простые массивы (одномерные и многомерные), каков синтаксис их инициализации, доступа к отдельным элементам, определения размера?
Опишите шаблон класса std::array<T, N>
, его назначение и преимущества использования по сравнению с простыми массивами.
Что такое строки в стиле C (C-style string) и какие средства работы с ними имеются в стандартной библиотеке C++?
Козлюк Д. А., Мохов А. С., Василькова П. Д. для кафедры Управления и информатики НИУ «МЭИ», 2019 г.