Pull to refresh
44
0

Пользователь

Send message

Отличный пример отвратительной реализации чего-то что этим не является.

Послушайте. Текущее решение — это не первое, что пришло мне в голову, и что я сразу же бросился реализовывать. И это не первая реализация типа File в 11l. Это результат размышлений на протяжении нескольких лет.

И принимая данное решение я прежде всего исходил из соображений практичности. С задачами типа «его хэндл/дескриптор можно передать в другой процесс» типичный программист прикладного ПО за всю свою карьеру сталкивается примерно ни разу. Признайтесь честно, ведь и вам не приходилось сталкиваться с такой задачей на практике?

К тому же, тип File в 11l — это аналог std::fstream (а точнее, std::ifstream/std::ofstream) в C++. Вас же не смущает невозможность передать std::fstream в другой процесс?

Для обёртки вокруг низкоуровневого файлового дескриптора будет использоваться отдельный тип (вроде FileDescriptor/FileHandle или даже os:File), когда он понадобится. А короткое имя File гораздо больше смысла имеет оставить за реализацией, которая и требуется чаще всего при типичной работе с файлами (прочитать или записать что-то в файл).

Всего-то надо позвать функцию ОС и сделать это удобно.

Если файл читается/пишется не целиком, то «всего-то позвать функцию ОС» не достаточно, т.к. вызов функции ОС осуществляет переключение в Ring 0 и обратно, что очень дорого. Для эффективной работы без буферизации чтения/записи не обойтись.

обычный булевый флаг-параметр, который совсем не всегда константа

Если такое и потребуется, то лучше использовать отдельный тип для этого (IOFile например). Тип File в 11l ориентирован на типичную работу с файлами, причём эффективную (за счёт буферизации) и безопасную (за счёт проверок на этапе компиляции).

IFile это очень плохое имя, даже с учетом всех рассуждений в комментарии

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

и почему не используется отдельный namespace?

Да, наверное, так и следует сделать, но я пока что не определился с названием этого namespace (всё-таки ffh — это неформальное название библиотеки, и мне не очень хочется его фиксировать в исходном коде).

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

Это проблема языка программирования, которую совсем не обязательно перекладывать на программиста (жертвовать красотой API в угоду производительности). К примеру, 11l прекрасно оптимизирует запись line = f.read_line() в аналог std::getline(f, line);. [Транспайлер 11l → C++ уже поддерживает эту оптимизацию, но только для записи в форме <str_var> = <file_obj>.read_line(), а запись var <new_str_var> = <file_obj>.read_line() внутри цикла будет оптимизироваться в 11lc.]

И, к слову, проблема с именем типов для файловых объектов в языке 11l решилась очень оригинальным образом: в исходном коде тип всегда называется просто File [также как в языках Ruby, Java/Kotlin, D], но фактический тип определяется на основе режима открытия файла: если режим не указан, то файл открывается для чтения и имеет тип FileForReading, а если указан режим WRITE или APPEND, то файл имеет тип FileForWriting. Соответственно, набор доступных методов в фактическом типе отличается и вызвать write() у открытого для чтения файла будет ошибкой компиляции. А если File используется в качестве имени типа аргумента функции, то фактически используется объединение типов FileForReading|FileForWriting|FileForReadingAndWriting. Причём, фактически используемые имена типов файловых объектов (например, FileForWriting) в исходном коде на 11l на данный момент вообще недоступны, а доступен только псевдотип File.

P.S.

Кстати говоря, насчёт оптимизации записи line = f.read_line() в f.read_line(line). Чисто теоретически, насколько я понимаю, такую оптимизацию можно было бы протащить и в C++! Дело в том, что в C++ есть такая штука, как "as-if rule". Это правило, которое разрешает любые преобразования кода, которые не изменяют наблюдаемое поведение программы. As-if rule, к примеру, разрешает C++ компилятору встраивать любые функции, даже те, которые не имеют пометки inline. А здесь говорится о возможности замены типа аргумента функции const T& на const T компилятором в том случае, когда T является простым типом, например int.
Однако, в результате замены line = f.read_line() на f.read_line(line) паттерн работы со строкой line будет отличаться [отличие заключается в "разогреве" строки]. Поэтому, если печатать в цикле значение line.capacity(), то компилятор не сможет применить "as-if rule", т.к. оптимизация в данном случае изменит наблюдаемое поведение программы. Однако, если обращений к line.capacity() в коде нет, и если глобальный оператор new не переопределён [в случае данной оптимизации оператор new будет вызываться гораздо реже], то наблюдаемое поведение программы не поменяется и C++ компилятор вполне может применить такую оптимизацию!

Не вижу причин, по которым ваш ~OFile не может быть реализован вот так:

~OFile() { close(); }

Такое тоже будет работать, но что это даёт? Просто будут выполняться некоторые лишние действия (fh.close(); вызовется повторно в деструкторе fh, а buffer_pos = 0; хотя ничего и не испортит, но избыточно).

Следовательно, в условном Traits::destroy вы можете делать флуширование, если вам это нужно.

Чтобы делать флуширование, необходим доступ к buffer и buffer_pos объекта OFile. Т.е. в ваш handle_holder помимо самого handle, необходимо добавить указатель на объект OFile, std::function или что-то вроде того.

Да уже понятно что у вас пунктик по этому поводу.

Вы так говорите, как будто это что-то плохое. :)(:
А вот что действительно печально, так это то, что конструктивной дискуссии у нас совсем не получается.
Я предлагаю [не только вам, а вообще всем читателям] простое решение вполне конкретной проблемы. Вы же всеми силами пытаетесь придумать, почему такое решение неправильно, и даже не хотите признавать наличие проблемы как таковой (проблемы очистки ресурсов в move assignment operator).

если использовать идиому "make temporary then swap", то модифицировать придется реализацию swap

А если использовать move_assign(), то ничего модифицировать не придётся. А функция swap() будет генерироваться автоматически на основе move constructor и move assignment operator. Да, получится чуток менее эффективно ручной реализации swap(), но... если честно, для чего может потребоваться swap() у объектов вроде OFile для меня остаётся загадкой.

Только вот если вашу реализацию использовать в рамках C++17, то там нужен std::launder

Для заменяемых типов (transparently replaceable) — не нужен. (Т.к. в данном случае имеет место реконструкция объекта того же самого типа, т.е. тип исходного объекта и вновь созданного полностью совпадают.) Но давайте не будем спорить на тему того, в чём мы детально не разбираемся. :)(:

Или у вас есть конкретный пример кода использования move_assign(), который некорректно работает без этого std::launder?

а возвращенное оператором placement new значение выбрасываете.

А куда его девать, это возвращённое значение? Мне вообще-то в данном случае нужен просто вызов move constructor-а. И мой код основан на реализации std::allocator<T>::construct(). Почему явный вызов конструктора в C++ записывается через placement new — вопрос не ко мне. Если можно вызвать move constructor как-то более правильно — дайте знать как.

А если он может делать close, то очистка ресурсов в move assignment operator не проблема.

Проблема. Т.к. одного умения делать close, увы, не всегда достаточно. Конкретный пример: деструктор в классе OFile вызывает метод flush(), который записывает все оставшиеся данные в буфере в файл. [Больше ничего деструктор класса OFile не делает, т.к. handle закрывается в деструкторе класса detail::FileHandle (сразу после выполнения кода деструктора OFile).]

Если взять вашу реализацию handle_holder, то толку от наличия TRAITS::destroy() будет немного, т.к. перед закрытием handle необходимо вызвать flush() у объекта-владельца handle, и в итоге move assignment operator для класса OFile реализовывать придётся как-то так:

    OFile &operator=(OFile &&f)
    {
        flush(); // из-за этого вызова приходится определять `operator=(OFile &&)`
        fh = std::move(f.fh);
        buffer = std::move(f.buffer);
        buffer_pos = f.buffer_pos;
        buffer_capacity = f.buffer_capacity;
        return *this;
    }

С помощью функции move_assign() можно, во-первых, существенно сократить код реализации operator=(OFile &&) [вот эта реализация] и, во-вторых, что более важно: при добавлении новых переменных-класса в OFile обновлять код реализации operator=(OFile &&) не потребуется.

Так что мой вопрос остаётся в силе: было бы интересно увидеть, какую альтернативу моему решению предлагаете вы?
[Уж случаем не идиому ли "make temporary then swap"? :)(:]

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

Так ведь давно уже все договорились.

Ответы: 0, 1, 1, 1, 2.

Код чтения файла по строкам для языков Python, C++ и Rust я уже приводил в этом комментарии.
Только что проверил в Nim [взяв за основу этот код, а также используя lines] и в Go [на основе кода отсюда]. Результат такой же.

Отличие в разных языках только в том, включается ли в прочитанную строку символ \n или нет (и функция read_line() в ffh имеет опциональный аргумент keep_newline для выбора желаемого поведения).

Изначальный посыл статьи был в неочевидной работе метода eof() в C++. И неправильное количество прочитанных строк получалось именно из-за него.

Вот здесь не все возвраты -1 будут ошибкой. Если в errno находится EINTR, то такую ситуацию надо обрабатывать как частичное чтение

Насколько я понимаю, для обычных файлов такого произойти не может. Это только для "slow" devices (terminal, pipe, socket).
И ни в одной реализации стандартной библиотеки языка Си (для Linux и FreeBSD) я не увидел проверки на EINTR в коде fread/refill: везде в случае, когда read возвращает число < 0, сразу возвращается ошибка. Поправьте, если ошибаюсь.

которое не успело прочитать совсем ничего (0 байт).

Возврат 0 — это ведь всегда признак конца файла, а не "прочитано 0 байт". (Т.е. функция read никак не может проинформировать о том, что было успешно прочитано 0 байт.)

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

Когда читаете файл последовательно сразу из нескольких параллельных потоков?

Как я уже говорил выше, это ультра редкий кейс, для поддержки которого неразумно замедлять чтение во всех остальных случаях (которых >99%). [И если такое и правда нужно — лучше использовать отдельную/другую реализацию чтения файлов в этом случае.]

В POSIX есть unlocked версии некоторых функций работы с файлами, например getc_unlocked, также есть функции ручной блокировки flockfile

Вы так говорите, как будто их кто-то использует. :)(:
Я вот узнал об этих функциях только в процессе написания данной статьи. Программисты хотят решать прикладные задачи, а не погружаться в детали реализации каждой библиотечной функции, которую они используют в своём коде.
К тому же, в файловых потоках C++ unlocked версий нет.

Так ведь можно вообще не иметь move_assign и делать просто *dest = std::move(other) в местах, где вы применяете move_assign.

Чтобы делать «просто *dest = std::move(other)» кто-то этот оператор присваивания перемещением должен реализовать: либо программист, либо компилятор. И функция move_assign() нужна для упрощения реализации этого оператора.

Но мотивация к появлению move_assign не смотря на обилие текста от меня ускользнула.

Понять, для чего нужен move_assign(), можно только на практике. Если у вас есть реализованный move assignment operator, то я могу показать, как его код можно упростить с помощью функции move_assign().

Но, за неимением вашего кода, давайте покажу на примере кода от Microsoft.

   if (this != &other)
   {
      // Free the existing resource.
      delete[] _data;

      // Copy the data pointer and its length from the
      // source object.
      _data = other._data;
      _length = other._length;

      // Release the data pointer from the source object so that
      // the destructor does not free the memory multiple times.
      other._data = nullptr;
      other._length = 0;
   }

Все эти строки можно заменить на одну строку move_assign(this, std::move(other));.

Нафиг нужен UniqueHandle, который не умеет закрывать хранящийся в нем дескриптор.

Ну я же привёл ссылку на коммит.
Благодаря UniqueHandle и move_assign код класса FileHandle сократился на 10 строчек.

Но, что даже более важно, благодаря UniqueHandle можно добавлять в FileHandle новые переменные-класса только в одном месте — в самом классе, т.е. править код move assignment operator и move constructor при этом не нужно, т.к. новые переменные-класса будут учитываться автоматически в сгенерированном компилятором коде move assignment operator и move constructor.

Если вы считаете, что UniqueHandle не нужен, то было бы интересно увидеть, какую альтернативу моему решению предлагаете вы (желательно не просто на словах, а в виде конкретного кода).

Может лучше пожалеть людей из комитета, которым придется читать подобный бред?

Весь proposal, по сути, это одно предложение из последнего абзаца, смысл которого заключается в том, чтобы C++ компилятор автоматически генерировал реализацию move assignment operator на основе деструктора и пользовательского move constructor. Это предложение не вводит никаких новых понятий (ни move_assign, ни UniqueHandle), а позволяет просто писать меньше кода, вот и всё.

Можно спросить, а почему такая мудреная реализация move_assign?

Ответ в виде мини-статьи

О, move_assign(), а также класс UniqueHandle — это моя новаторская технология. :)(:

Недавно я добавлял переменную-класса, у которого был move-конструктор. В класс-то я добавил, а вот move-конструктор поправить я забыл. Когда обнаружил ошибку, я задался вопросом: как бы сделать так, чтобы компилятор автоматически обновлял/корректировал move-конструктор при добавлении переменных-класса? Тут я вспомнил, что если явно move-конструктор не объявлять [либо объявлять без тела с пометкой = default;], то он генерируется компилятором автоматически. Хорошо, а что мешало в данном случае генерировать компилятору корректный move-конструктор [или, другими словами, почему не подходил сгенерированный компилятором move-конструктор, почему он некорректный]? Проблема оказалась в одной единственной переменной-класса типа HANDLE, которая в move-конструкторе инициализировалась значением из другого объекта, а в этом другом объекте устанавливалась в INVALID_HANDLE_VALUE (т.е. что-то вроде handle = other.handle; other.handle = INVALID_HANDLE_VALUE;).

«А что, если этот handle обернуть в некий вспомогательный класс?» — подумал я.

Так родился UniqueHandle.

("Unique", т.к. он запрещает копирование, а позволяет только перемещать себя, по аналогии с std::unique_ptr.)

UniqueHandle<HandleType, HandleType default_value> пытается вести себя как handle типа HandleType: у него определены оператор неявного приведения к типу HandleType, а также оператор присваивания значения типа HandleType. Но в отличие от сырого handle, переменная-класса типа UniqueHandle умеет себя перемещать (кодом handle = other.handle; other.handle = default_value;) и автоматически инициализироваться значением default_value.

Так я избавился от объявления move-конструктора.

И т.к. это происходило во время разработки библиотеки ffh, я решил, что переменную handle/fd в классе FileHandle можно также заменить на UniqueHandle. Вот соответствующий коммит.

Далее встал вопрос — а что делать с оператором присваивания перемещением (move assignment operator)? Если посмотреть на код уже существующих операторов присваивания перемещением, то легко заметить, что сначала идёт освобождение ресурсов, а затем перемещение, причём код освобождения ресурсов в точности совпадает с кодом деструктора, а код перемещения совпадает с кодом move-конструктора.

Таким образом, реализацию оператора присваивания перемещением при наличии move-конструктора можно генерировать автоматически! Именно это и делает вспомогательная функция move_assign(): сначала вызывает деструктор (dest->~Ty();), а затем — move-конструктор (new(dest)Ty(std::move(other));).

К слову, Microsoft предлагает делать наоборот: «you can eliminate redundant code by writing the move constructor to call the move assignment operator». (Т.е. предлагает реализовывать move constructor как *this = std::move(other);.) Но такое решение хуже тем, что реализация move assignment operator сложнее реализации move constructor, а также тем, что при этом выполняются лишние действия: вначале конструируется пустой объект, который сразу же будет разрушен внутри реализации move assignment operator (т.е. выполняется цепочка «конструктор-по-умолчанию—деструктор—move-конструктор»). Поэтому лучше выражать move assignment operator через move constructor, а не наоборот.

Почему бы не реализовать в классе UniqueHandle ещё и move assignment operator? Потому, что UniqueHandle не умеет корректно закрывать handle. И даже если его научить (посредством дополнительного шаблонного параметра — класса, у которого определена статическая функция close(), которая закрывает handle), то всё равно иногда требуется произвести какие-то дополнительные действия перед тем, как закрывать handle, например flush — записать оставшиеся в буфере данные в файл, handle которого находится в данном UniqueHandle. (Т.е. иногда нужны такие дополнительные действия перед закрытием handle, которые никак не получится сделать на уровне UniqueHandle.)

На данный момент C++ компилятор автоматически добавляет удалённый [т.е. помеченный как = delete;] оператор присваивания в случае, когда в классе есть определяемый пользователем конструктор перемещения (а определяемого пользователем оператора присваивания нет). Но я считаю, что компилятор C++ в этом случае может автоматически генерировать реализацию move assignment operator на основе деструктора и пользовательского конструктора перемещения, таким образом делая в точности то, что делает функция move_assign(). Возможно, если не поленюсь, напишу proposal к стандарту C++. :)(:

Почему нельзя было просто сделать *dest = std::move(other)?

Если функцию move_assign() "реализовать" как *dest = std::move(other);, то при выполнении кода detail::FileHandle<true> fh; fh = detail::FileHandle<true>(); получится бесконечная рекурсия и, как следствие, переполнение стека. Т.к. FileHandle::operator=(FileHandle &&fh) реализован как move_assign(this, std::move(fh));.

Это жёсткий диск 2009 года на 250 Гб.

Да, его, конечно, можно отнести к «множеству существующих в настоящее время HDD», но в статье речь была об «используемых в настоящее время». Вы правда используете его по назначению в 2024 году?

множество существующих в настоящее время HDD всё ещё имеют размер сектора 512 байт. У меня таких есть несколько

Хотелось бы уточнить этот момент. Можете сообщить название/модели ваших HDD?
Очень сомневаюсь, что там 512n (n — native), а не 512e (e — emulation).

несколько потоков читают из-одного файлового дескриптора (странный кейс, но в целом возможный и валидный)

Это ультра редкий кейс [если мы говорим про последовательное буферизованное чтение файла, а не выборочное чтение блоками из разных мест файла по заданному смещению], для поддержки которого неразумно замедлять чтение во всех остальных случаях (которых >99%).

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

Меня больше задевает обработка ошибок исключительно через исключения.

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

Я провёл тесты с опциями компилятора /EHs-c- /D_HAS_EXCEPTIONS=0, дополнительно заменив в исходном коде ffh все throw на abort(), и не обнаружил разницы в производительности чтения посредством библиотеки ffh. Однако чтение посредством std::ifstream благодаря отключению исключений ускорилось процентов на 20-30. (Хотя, похоже, это связано не с самими исключениями, а с изменением логики работы при отключении исключений.)

Как вы думаете, как работает print? Почему по вашему, две команды print выводят две строки, а не два символа в одну строку?

Да, в отличие от простого sys.stdout.write() у функции print() есть дополнительный аргумент end, который по умолчанию равен символу новой строки \n.
Упрощённо, реализацию print() с одним аргументом в Python можно записать так:

def print(arg, end = "\n"):
    sys.stdout.write(str(arg))
    sys.stdout.write(end)

Более подробно, как работает print() в реализации Python можно посмотреть здесь.

при печати одного символа, печатается 3 байта. Сам байт, возврат каретки и новая строка.

Так происходит только в Windows. Причём на уровне записи в текстовый файл.
Хотя фактически текстовые файлы с разделителями строк в стиле Unix (одиночный \n, код символа — 10) уже давно поддерживаются всеми программами под Windows. Даже встроенный в Windows 10 блокнот научился нормально открывать такие файлы.
И то, что при сохранении текстовых файлов Блокнот или Microsoft Word разделяют строки парой символов \r\n, это исключительно по религиозным соображениям — в Microsoft не хотят признавать, что это не имеет смысла.

Ваш код будет выводить лишнюю пустую строку в конце. Если файл f.txt состоит всего из одной строки (причём даже не важно, есть ли символ новой строки в конце файла или нет), то ваш код вызовет функцию print() два раза, хотя строка в файле одна.

А мануал почитать на туже readline у Python?

Очень похоже, что вы взяли этот Python-код из какой-то левой статьи, вроде такой. В мануале такого бреда не встретишь.

Если и читать файл посредством readline(), то код обычно используют такой (на основе примера отсюда):

with open("myfile.txt") as fp:
    while True:
        line = fp.readline()
        if line == "":
            break
        print(line.rstrip())

На всех современных устройствах, включая nvme диски обычно используется размер сектора в 512 байт.

Эти устройства лишь сообщают о том, что размер сектора у них 512 байт. Но физически размер сектора уже везде 4096 байт и более. Вы разве не слышали про 512e?

Сектора - это про адресуемые единицы на устройстве.

Именно. Т.е. когда вы запрашиваете у устройства 512 байт, то фактически будет читаться 4096 байт и более. Поэтому запрашивать меньше 4096 байт не имеет смысла.

Все мои HDD тоже имеют 512, хотя они старые

Укажите номера моделей ваших HDD. Очень сомневаюсь, что там 512n.

К примеру, в моём ноутбуке 2014 года был установлен HDD ST500LT012-1DG142 на 500 Гб, который я уже давно заменил на SSD. Так вот, в спецификации от Seagate написано:
Bytes per sector   512 (logical) / 4096 (physical)

Неужели ваши диски более старые?

А почему при чтении используется readline(), вместо потокового ввода?

Вы имеете в виду getline()?

Это же c++? Логично сразу учить студентов корректно работать с потоковым вводом.

А на что вы предлагаете заменить getline()?

Т.к. строки в файле идут в виде <слово> - <перевод>, то при чтении строки parser - синтаксический анализатор посредством кода f >> s прочтётся 4 строки: parser, -, синтаксический и анализатор.

Вот соответствующий код на C++

(Ссылка на playground)

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

using namespace std;

int main() {
    ifstream f("/uploads/words.txt");
    string s;
    int total = 0;

    while (f >> s) {
        cout << s << endl;
        total++;
    }
    
    cout << "Total lines: " << total << endl;
}

"\n" - это символ не конца строки, а символ разделителя строк - и если это держать в уме, то все работает четко и корректно.
Т.е. кол-во строк в файле = кол-во "\n" в файле + 1.
Нет ни одного "\n" в файле - есть только одна строка, есть - минимум две строки.

А вот разработчики языков Python, C++ и Rust с вами не согласны. :)(:

Возьмём для примера два файла with_ending_newline.txt и without_ending_newline.txt.
Первый содержит "a\nb\n", а второй — "a\nb".
Если читать эти файлы по строкам используя readlines()/getline()/read_line(), то будет прочитано две строки во всех случаях:

Python

(Ссылка на playground)

total = 0

for line in open('/uploads/with_ending_newline.txt').readlines():
    print(line)
    total += 1

print('Total lines: ', total)
C++

(Ссылка на playground)

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

using namespace std;

int main() {
    ifstream f("/uploads/with_ending_newline.txt");
    string s;
    int total = 0;

    while (getline(f, s)) {
        cout << s << endl;
        total++;
    }
    
    cout << "Total lines: " << total << endl;
}
Rust с использованием lines()

(Ссылка на playground)

// [https://stackoverflow.com/questions/45882329/read-large-files-line-by-line-in-rust <- google:‘rust read lines from file’]
use std::fs::File;
use std::io::{self, prelude::*, BufReader};

fn main() -> io::Result<()> {
    let file = File::open("/uploads/with_ending_newline.txt")?;
    let reader = BufReader::new(file);
    let mut total = 0;

    for line in reader.lines() {
        println!("{}", line?);
        total += 1;
    }
    
    println!("Total lines: {}", total);

    Ok(())
}
Rust с использованием read_line()

(Ссылка на playground)

// [https://stackoverflow.com/questions/63627687/why-does-rusts-read-line-function-use-a-mutable-reference-instead-of-a-return-v <- https://stackoverflow.com/search?q=rust+read_line]
use std::fs::File;
use std::io::{self, prelude::*, BufReader};

fn main() -> io::Result<()> {
    let file = File::open("/uploads/with_ending_newline.txt")?;
    let mut reader = BufReader::new(file);
    let mut total = 0;
    let mut line = String::new();

    while reader.read_line(&mut line)? != 0 {
        println!("{}", line);
        total += 1;
        line.clear();
    }
    
    println!("Total lines: {}", total);

    Ok(())
}

Обратите внимание, вывод для файлов with_ending_newline.txt и without_ending_newline.txt немного отличается, но в обоих случаях Total lines: 2.

автор ... не понимает, что такое "\n" - и от этого вся проблема и борьба с ветрянными мельницами.

Автор пытается обратить внимание читателей на то, что функции feof()/eof() в C/C++ однозначно можно признать неудачными/[сбивающими с толку], т.к. во всех новых языках программирования ничего подобного этим функциям просто нет — в Python вообще никакого eof-а нет, а в Nim, Rust и Swift eof() работает как в старом добром Паскале (ну только называется немного по другому: endOfFile() в Nim, !has_data_left() в Rust, availableData.count == 0 в Swift).

Зря вы так.

На уточняющие вопросы студентов я всегда охотно отвечаю, но прямого вопроса «почему у меня читается лишняя пустая строка» я не получал, а заметил это сам в процессе отладки решения одного из студентов.

«Смысл этого чтения» излагается в тексте задания и в данной статье не приводится, так как приводить полный текст задания в данной статье я посчитал излишним. Суть в том, что на входе есть текстовый файл со списком слов на английском языке и их переводом.

Каждая строка файла содержит:

<слово> - <перевод>

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

Не нравится пустая строка - ну не сохраняй ее себе…

А как вы предлагаете отличать пустую строку в самом конце файла от пустых строк в середине?

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

Я проблемы в этом не вижу.

Смотрите, в чём проблема. Допустим, есть такая программка на Python:

print('a')
print('b')

Она выводит в консоль две строки:

a
b

Теперь, если перенаправить вывод этой программы в файл (python prog.py > out.txt), то логично будет ожидать, что в файле будут всего две строки, а не три ['a', 'b' и ''].

Это значит, что строк столько же, сколько и этих символов + 1 вначале файла.

Если следовать такой логике, то получится, что в файле out.txt три строки (два символа \n + 1), а должно быть две.

Information

Rating
Does not participate
Location
Владивосток, Приморский край, Россия
Registered
Activity