План ЛР № 4 по «Технологии программирования» 16 декабря 2016 г.

Это не задание, это «прохождение» первой половины ЛР № 4 и вспомогательные материалы ко второй половине. Фрагменты кода могут содержать ошибки (не специально).

1. Функции print_in_*() (под руководством)

Разобьем задачу на более простые части.

Начнем с print_in_hex(). Байт — это 8 бит, то есть две цифры в шестнадцатеричной системе. Чтобы напечатать байт, нужно напечатать цифру, соответствующую его старшей и младшей половине (они называются nibble). Любой блок данных (по адресу в нетипизированном указателе void*) — это массив байт; нужно только указать компилятору рассмотреть void* как uint8_t*. Очевидно, чтобы напечатать массив байт, нужно напечатать каждый байт в цикле.

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

Итак, элементарные задачи:

  1. Напечатать шестнадцатеричную цифру для значения от 0 до 15.
  2. Извлечь из байта младший nibble как число от 0 до 15.
  3. Извлечь из байта старший nibble как число от 0 до 15.
  4. Напечатать байт в шестнадцатеричном виде как два nibble.
  5. Преобразовать void* в uint8_t*.
  6. Напечатать size элементов массива по адресу в uint8_t* (hex).
  7. Напечатать байт в двоичном виде.
  8. Напечатать size элементов массива по адресу в uint8_t* (binary).

1.1. Напечатать шестнадцатеричную цифру для значения от 0 до 15

Напишем вспомогательную функцию, которая будет представлять значение от 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 и т. д.).

Важный момент — самопроверка. В стандартном заголовочном файле <cassert> есть полезный макрос assert(). Если передать в assert() выражение, и оно окажется ложным, программа аварийно завершится, и будет напечатано это выражение. Если же оно истинно, ничего сделано не будет. Чтобы проверить работу 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(), добавив assert(0x0 <= i && i <= 0xf) в начало функции.

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

1.2. Извлечь из байта младший nibble как число от 0 до 15

Задача сводится к тому, чтобы из восьми бит четыре младших оставить такими, как есть, а четыре старших обнулить. Типовое решение — наложить битовую маску. Битовая маска следующая: 0b00001111, или 0x0f, — в ней единицы стоят в тех позициях, биты в которых нужно извлечь. Логическое «И» (&) бита x с нулем дает 0, а с единицей - x, то есть byte & mask даст искомый младший nibble. Решение для математиков — взять остаток от деления байта на 32 (0b00010000).

1.3. Извлечь из байта старший nibble как число от 0 до 15

Можно разбить эту задачу еще на две: выделение старших разрядов байта и их перемещение (сдвиг) на позиции младших разрядов. Какая маска подойдет для выделения? Очевидно, 0xf0 (0b11110000). Сдвиг вправо на 4 разряда делается оператором >>: byte >> 4. По стандарту C++, старшие биты результата будут равны 0, поэтому на самом деле выделять старшие биты не нужно. Сдвиг вправо на 4 позиции математически равносилен делению на 2⁴, но при работе с битами сдвиг лучше выражает суть дела.

1.4. Напечатать байт как два nibble

На этом шаге каждый должен реализовать print_in_hex() для байта:

void
print_in_hex(uint8_t byte) {
    cout << nibble_to_hex(byte >> 4)
         << nibble_to_hex(byte & 0xf);
}

Для самопроверки следует попробовать напечатать байты 0x0, 0xab, 0xff.

1.5, 1.6. Преобразовать void* в uint8_t* и напечатать массив этих байт

Заключим преобразование типов в функцию. В реальной программе это было бы излишне, но функция — это ведь еще и помощь программисту в структурировании программы, и раз так удобнее рассуждать, то и сделаем.

const uint8_t* as_bytes(const void* data);

Здесь важны ключевые слова 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 << ' ';
        }
    }
}

Указание компилятору, что значение одного типа нужно трактовать как значение другого, называется приведением типов. Из лекций известно, что в данном случае корректна такая реализация:

const uint8_t*
as_bytes(const void* data) {
    return reinterpret_cast<const uint8_t*>(data);
}

На этом шаге каждый должен перенести код в свою программу. Самопроверка: завести переменные типа uint8_t, uint16_t, uint32_t и дать им одно и то же значение, 0x42. Напечатать их через новую функцию и убедиться визуально, что единственным ненулевым байтом будет 0x42 в каждом случае, а всего байт будет столько, сколько ожидается. Вторым параметром следует передавать sizeof, например:

uint32_t u32 = 0x42;
cout << "u32 bytes: ";
print_in_hex(&u32, sizeof(u32));
cout << '\n';

1.7. Напечатать байт в двоичном виде

Известен способ перевода в двоичную систему путем взятия остатков от деления на два, однако, порядок остатком получается обратным порядку бит. В программе проще проверять биты, начиная со старшего, и печатать 0, если бит равен 0, и 1, если бит равен 1. Для выделения бита можно воспользоваться маской: старший бит выделяется как 0b10000000, или (0x1 << 7), младший — маской (0x1 << 0). После наложения маски с одним установленным битом в результате останется либо 0 (если соответствующий бит не установлен), либо не-ноль, если установлен. Выделим эту логику в функцию по аналогии с nibble_to_hex():

char
bit_digit(uint8_t byte, uint8_t bit) {
    if (byte & (0x1 << bit)) {
        return '1';
    }
    return '0';
}

Сдвиги на 7, 6, ..., 0 бит логично делать циклом. Итого:

void
print_in_binary(uint8_t byte) {
    for (uint8_t bit = 7; bit > 0; bit--) {
        cout << bit_digit(byte, shift);
    }
}

На этом шаге каждый должен перенести код в свою программу. Самопроверка: перевести в двоичное представление и напечатать числа из лекционного слайда про двоичные операции (исходные два числа и результаты всех действий).

1.8. Напечатать блок данных в двоичном виде

Очевидно, что приведение типов не отличается от случая для шестнадцатеричной системы. Каждый должен написать и проверить по аналогии с 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 << ' ';
        }
    }
}

После выполнения у каждого контролируются результаты.

В двоичной системе 0x42 будет 0b00101010. Этот байт должен стоять первым при печати целого числа любой длины, а за ним — байты с нулями.

2. Битовый калькулятор (самостоятельно)

На этом шаге каждый должен выполнить пункт 2 задания на ЛР № 4 с сайта, продемонстрировать корректную работу программы и занести код и результаты в отчет в виде текста.

Вопрос: почему 1025 (0b00000100'00000001, 0x0401) представлено байтами 01 04, а не наоборот?

Ответ: потому что на x86 (Intel) порядок байт от младшего к старшему (little-endian), то есть младший байт в памяти расположен первым.

3. Считывание файла и разбиение его текста на массив строк (под руководством)

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

Укрупненный алгоритм таков:

  1. Считать текст из файла в память, выделив для этого участок.
  2. Разбить текст на не пустые строки, выделяя по участку для каждой.
  3. Удалить участок памяти, выделенный под текст, и все участки, выделенные под строки.

На данном этапе имеет смысл решить, какие ресурсы будет использовать программа (подо что нужно выделять участки памяти), и какие части программы будут управлять ими (выделять и освобождать).

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

Вот как может выглядеть программа, если изолировать оговоренные части в функции:

auto text = load_file(path);
auto line_count = count_nonempty_lines(text);
auto lines = allocate_lines(line_count);
to_lines(text, lines);
deallocate(text, lines);

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

На этом шаге каждый должен перенести к себе прототип программы и убедиться, что он компилируется:

3.1. Считывание содержимого текстового файла в память как строки C

Чтобы считать содержимое файла в память, необходимо:

  1. Открыть файл для чтения данных.
  2. Определить размер файла в байтах.
  3. Выделить блок памяти достаточного размера.
  4. Считать содержимое файла в этот блок, чтобы получилась строка C.
  5. Завершить работу с файлом.
  6. По окончании работы с текстом удалить его из памяти.

В случае невозможности загрузки будем возвращать нулевой указатель. Итак,

const char*
load_file(const char* path) {

В стандартной библиотеке C++ имеется класс ifstream (файл <fstream>) для чтения файлов. Путь к файлу передается в конструктор при создании объекта. Ниже объявляется переменная input класса ifstream, которая соответствует открытому для чтения файлу по пути path:

    ifstream input(path);

Объекты ifstream (как и другие потоки) имеет метод bad(), который возвращает false после ошибок, в том числе при невозможности открыть файл. Им можно воспользоваться для проверки:

    if (input.bad()) {
        return nullptr;
    }

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

    input.seekg(ios::end, 0);
    auto size = input.tellg();

Для дальнейшей работы нужно переместиться обратно в начало файла:

    input.seekg(ios::beg, 0);

Выделение памяти производится оператором new. Какой объем памяти необходим под строки C с текстом файла длиной size? Строки C, помимо полезных символов, включают также и завершающий '\0', значит, нужно (size + 1) элементов:

    char* data = new char[size + 1];

У ifstream есть метод для считывания заданного количества байт в область памяти:

    input.read(data, size);

Завершающий '\0' — это особенность C, в файле этого символа нет, поэтому его нужно записать в массив самостоятельно:

    data[size] = 0;

Работа с файлом закончена. Можно закрыть его методом close() в ifstream, но нет необходимости: он будет вызван в деструкторе объекта input, поэтому можно просто завершить функцию:

    return data;
}

Самопроверка: записать полностью функцию load_file() и проверить её работу, напечатав текст после его считывания. Путь к файлу можно вводить и через std::string, чтобы преобразовать её к const char*, нужен метод c_str().

3.2. Разбиение текста на массив (не пустых) строк

3.2.1. Подсчет количества строк

В начале файла могут быть пустые строки, очевидно, их нужно пропустить, то есть, взяв указатель на начало текста, получить другой указатель — на начало первой не пустой строки. Пустые строки — это непрерывная последовательность символов '\n'. Если таких строк нет, пусть это будет указатель на конец текста, то есть на его завершающий '\0'. Далее необходимо найти конец строки, то есть указатель на первый встреченный в ней символ '\n'. Если после этого пропустить все пустые строки, мы вернемся к тому же состоянию, что и перед началом очередной строки, то есть выделение не пустой строки можно выполнять циклически. Итого, алгоритм:

  1. Пропустить пустые строки (найти указатель на начало первой не пустой).
  2. Если достигнут конец теста, прекратить подсчет.
  3. Увеличить счетчик строк на 1 (изначально он был 0).
  4. Найти конец строки (указатель на это место).
  5. Пропустить пустые строки, начиная с найденного конца строки.
  6. Перейти к пункту 2.

Если считать, что уже реализована функция пропуска пустых строк const char* skip_empty_lines(const char* text) и функция поиска конца строки const char* find_line_end(const char* text), реализация может быть такой:

size_t
count_nonempty_lines(const char* text) {
    size_t count = 0;
    const char* line_start = skip_empty_lines(text);
    while (*line_start != '\0') {
        count++;
        const char* line_end = find_line_end(line_start);
        line_start = skip_empty_lines(line_end);
    }
    return count;
}

Для поиска конца строки нужно проверять символ за символом, пока очередной не окажется '\n' или завершающим '\0'. В любом случае нужно вернуть адрес того символа, до которого дошел поиск:

const char*
find_line_end(const char* text) {
    size_t i = 0;
    while (text[i] != '\0' && text[i] != '\n') {
        i++;
    }
    return &text[i];
}

Пропуск пустых строк, то есть подряд идущих символов '\n', тривиален:

const char*
skip_empty_lines(const char* text) {
    size_t i = 0;
    while (text[i] == '\n') {
        i++;
    }
    return &text[i];
}

Отдельной проверки на окончание строки не требуется, потому что '\0' != '\n', и цикл прервется автоматически.

3.2.2. Выделение памяти под массив строк

Выделение памяти делается оператором new. Функция allocate_lines() носит практически декоративный характер:

char**
allocate_lines(size_t line_count) {
    return new char* [line_count];
}

Результат — адрес области памяти с указателями, то есть еще потребуется выделять блоки под сами строки, указатели на которые и будут сохраняться в динамическом массиве.

3.2.3. Разбиение текста на предложения

Разделение текста на предложения похоже на их подсчет, но вместо простого увеличения счетчика нужно выделять память под предложение и копировать в нее часть текста.

void
to_lines(const char* text, char** lines) {
    size_t count = 0;
    const char* line_start = skip_empty_lines(text);
    while (*line_start != '\0') {
        const char* line_end = find_line_end(line_start);

        const size_t line_length = line_end - line_start;
        lines[count] = new char[line_length + 1];
        strncpy(lines[count], line_start, line_length);

        count++;

        line_start = skip_empty_lines(line_end);
    }
    return count;
}

3.3. Освобождение памяти

Необходимо освободить в функции deallocate():

  1. Текст, загруженный из файла:

    delete[] text;

  2. Каждую из строк, скопированных из текста.

    for (size_t i = 0; i < line_count; i++) { delete[] lines[i]; }

  3. Массив указателей на строки (уже освобожденные):

    delete[] lines;

Необходимое значение line_count нужно добавить в параметры функции и передавать из main(); также вместо deallocate() вписать её код в конец main().

На этом шаге каждый должен завершить программу и проверить её работу, добавив после разбиения текста на строки их печать:

for (size_t i = 0; i < line_count; i++) {
    cout << lines[i] << '\n';
}

4. Разбиение теста на предложения, подсчет слов в них (самостоятельно)

Необходимо разбивать текст не на строки, а на предложения — участки текста, ограниченные '.', '!' или '?' (возможно, подряд: "...", "?!"). Затем необходимо подсчитать количество слов в каждом предложении; слова разделяются одним или несколькими пробелами или запятыми. Например, вот два предложения, из шести и трех слов:

News,from beyond the Narrow Sea.  Haven't you heard?!
    ↑                           ↑↑
нет пробела                 два пробела

Наконец, требуется напечатать предложения в порядке убывания количества слов. В конце программы все выделенная память должна освобождаться через delete[].

По сути, нужно обобщить уже реализованные функции, чтобы они могли делить текст не только по символу конца строки, а по любому символу из набора (который можно представить как строку C):

const char* find_any(const char* text, const char* what_to_find);
const char* skip_any(const char* text, const char* what_to_skip);

На самом деле, похожие функции уже есть в стандартной библиотеке: strpbrk() и strspn() из <cstring>. Можно воспользоваться ими.

Функция для подсчета частей текста (ранее строк, теперь — между разделителями) понадобится и чтобы выделить предложения, и чтобы подсчитать слова в них:

size_t count_parts(const char* text, const char* delimiters);

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

void split(const char* text, const char* delimiters, char** parts);

Каждый должен продемонстрировать код и работу итоговой программы и занести это в отчет в виде текста.


При наличии времени имеет смысл пройтись по вопросам защиты ЛР № 4, по крайней мере, кто не будет знать, что делать, может заняться этим.


Козлюк Д. А. для кафедры Управления и информатики НИУ «МЭИ», 2016 г. Система Orphus