JDD: ReadLine - Kovalevskyi-Academy/AcademyWiki GitHub Wiki
Disclaimer: Это одно из самых сложных заданий в курсе. Поэтому это нормально, что сначала задача может показаться легкой, а потом знатно закипеть мозг и казаться, что большинство не сильно страдает при решении задачи и даже получает от этого удовольствие, а вы тот, кто мучается за всех остальных.
- Введение
- Один из возможных подходов к решению
- Дополнительные ньюансы, на которые нужно обратить внимание
- Дополнительные материалы
Задание состоит в том, чтобы реализовать свой класс BufferedReader и метод char[] readLine()
и boolean hasNext()
.
В этом задании нам нужно разработать обертку над классом Reader, который принимается в конструктор. Задача обертки (декоратора) состоит в том, чтобы "оборачивать" какой-то класс (в нашем случае это любой класс, который является наследником абстрактного класса Reader, например CharArrayReader, FileReader и т.д.) и при этом добавлять какую-то дополнительную свою функциональность на базе обернутого класса (который мы передаем в конструктор).
В нашем случае, нам нужно добавить дополнительную функциональность в виде возможности читать с I/O построчно через метод readLine, а не только посимвольно или пачкой идущих подряд символов произвольной длины, как это позволяет Reader через метод read. Это можно сделать с помощью техники буферизации - наш StdBufferedReader сначала будет накапливать в своем внутреннем буфере (обычном массиве в памяти с каким-то заранее указанным размером) символы со входяшего потока из Reader, а потом уже с этими символы работать уже c применением какой-то более сложной логики чем прочитал символ - отдал тот же символ (например, отдавать только символы, которые в составе какой-то строчки) .
Зачем нужна буферизация? Причина проста, проблемы с эффективностью! Данные в буфере фактически хранятся в памяти, а исходные данные могут храниться на жестком диске, на флэш-памяти, или передаваться по сети. Например, чтение данных из памяти происходит как минимум в 10 раз быстрее, чем чтение данных с жесткого диска.
Еще один вопрос, который у вас может возникнуть, это почему бы просто не прочитать все данные в буфер сразу? Ведь чем больше буфер, тем больше символов мы можем туда запихнуть и с ними уже быстро работать, а не читать все время пачками символов с источника. Во-первых, чтение всех данных и запись их в буфер может занять много времени при большом объеме данных, во время которого мы не сможем ничего делать с символами из буфера пока он не заполнится, во-вторых, у памяти емкость не такая большая, как у жесткого диска.
Представим, что у нас нет BufferedReader и нам нужно читать построчно. Для этого нам понадобится
любой наследник Reader, который умеет читать символы из источника, например, CharArrayReader,
который имеет для чтения метод int read()
, что считывать посимвольно и
метод int read(char b[], int off, int len)
который считывает кусок символов, которые находятся
друг за другом, то есть последовательно (размер пачки символов, который мы хотим считать за раз
указывается в len). Так вот, если мы хотим считать первую строчку из файла или иного источника со
следующим содержимым:
Howdy, stranger!
This is Hauser.
То, если мы начнем считывать входящий поток символов, который можно логически представить в виде
строки "Howdy, stranger!\nThis is Hauser."
, или этот же поток в виде массива
символов ['H', 'o', 'w', 'd', 'y', ',', ' ', 's', 't', 'r', 'a', 'n', 'g', 'e', 'r', '!', '\n', 'T', 'h', 'i', 's', ' ' , 'i', 's', ' ' , 'H', 'a', 'u', 's', 'e', 'r', '.']
, то нам
придется вручную вызывать у него метод int read() столько раз, сколько символов до первого
попавшегося символа '\n' или дергать метод int read() в цикле до тех пор пока не встретим '\n'.
Метод int read(char b[], int off, int len) мы не можем использовать, потому что не знаем когда нам
может попасться перенос строки (использовать len размером 1 нет смысла, так как это будет аналогично
вызову метода посимвольного считываня int read(), а если мы укажим len как 2, то можем и вовсе
пропустить символ переноса строчки когда придет строка вида "\n\n\n\n").
Пример 1. Посимвольное считывание:
var text=new String("Howdy, stranger!\nThis is Hauser.");
final var bytes=text.getBytes(StandardCharsets.UTF_8);
var reader=new InputStreamReader(new ByteArrayInputStream(bytes));
var sb=new StringBuilder();
char ch;
while('\n'!=(ch=(char)reader.read())){
sb.append(ch);
}
System.out.println(Arrays.toString(sb.toString().toCharArray())); // ['H', 'o', 'w', 'd', 'y', ',', ' ', 's', 't', 'r', 'a', 'n', 'g', 'e', 'r', '!']
Мы же хотим создать класс StdBufferedReader c методом readLine(), который скроет от пользователя всю работу с символами и циклами. Пользователь хочет передать источник (reader), и получать при каждом вызове readLine следующую строчку до тех пор пока источник не скажет, что данных больше нет:
Пример 2.
var text=new String("Howdy, stranger!\nThis is Hauser.");
final var bytes=text.getBytes(StandardCharsets.UTF_8);
var reader=new InputStreamReader(new ByteArrayInputStream(bytes));
var bufferedReader=new StdBufferedReader(reader);
var result=bufferedReader.readLine();
System.out.println(result); // ['H', 'o', 'w', 'd', 'y', ',', ' ', 's', 't', 'r', 'a', 'n', 'g', 'e', 'r', '!']
result=bufferedReader.readLine();
System.out.println(result); // ['T', 'h', 'i', 's', ' ' , 'i', 's', ' ' , 'H', 'a', 'u', 's', 'e', 'r', '.']
result=bufferedReader.readLine();
System.out.println(Arrays.toString(result)); // []
result=bufferedReader.readLine();
System.out.println(Arrays.toString(result)); // []
Как видите, как только новых строчек в источнике уже нет, readLine() будет возвращать при
последующих вызовах пустой массив символов. Если пользователь вашего класса захочет прочитать все
строчки в источнике и остановиться как только они закончились (потому что дальше читать бессмыслено)
то ему придется проверять, что результат вызова метода readLine() не равен []. Для удобства
пользователя нужно добавить метод boolean hasNext()
, который будет возвращать true/false в
зависимости от того есть ли еще строчки в источнике или нет.
Пример 3.
var text=new String("Howdy, stranger!\nThis is Hauser.");
final var bytes=text.getBytes(StandardCharsets.UTF_8);
var reader=new InputStreamReader(new ByteArrayInputStream(bytes));
var bufferedReader=new StdBufferedReader(reader);
bufferedReader.hasNext(); // true
var result=bufferedReader.readLine();
System.out.println(result); // ['H', 'o', 'w', 'd', 'y', ',', ' ', 's', 't', 'r', 'a', 'n', 'g', 'e', 'r', '!']
bufferedReader.hasNext(); // true
bufferedReader.hasNext(); // true
result=bufferedReader.readLine();
System.out.println(result); // ['T', 'h', 'i', 's', ' ' , 'i', 's', ' ' , 'H', 'a', 'u', 's', 'e', 'r', '.']
result=bufferedReader.readLine();
System.out.println(Arrays.toString(result)); // []
bufferedReader.hasNext(); // false
Пример 4.
var text=new String("Howdy, stranger!\nThis is Hauser.");
final var bytes=text.getBytes(StandardCharsets.UTF_8);
var reader=new InputStreamReader(new ByteArrayInputStream(bytes));
var bufferedReader=new StdBufferedReader(reader);
while(bufferedReader.hasNext()){
System.out.println(bufferedReader.readLine());
}
//Howdy, stranger!
//This is Hauser.
Скелет нашего класса StdBufferedReader выглядит следующим образом:
public class StdBufferedReader implements Closeable {
public StdBufferedReader(Reader reader, int bufferSize) {
}
public StdBufferedReader(Reader reader) {
}
// Returns true if there is something to read from the reader.
// False if nothing is there
public boolean hasNext() throws IOException {
}
// Returns a line (everything till the next line)
public char[] readLine() throws IOException {
}
// Closing
public void close() throws IOException {
}
}
В этом подходе нам понадобится как минимум 3 поля класса:
- Буфер в виде массива символов char[], который мы будем заполнять теми символами, которые получаем
из Reader. Назовем его
buffer
. Размер буфера, если он не передан через конструктор явно, можно выбрать произвольный. - Указатель(курсор) буфера типа int, c помощью которого мы будем перемещать по буферу при считывании
символов в нем. Он хранит индекс, по которому мы последний раз считывали с буфера. Назовем
его
bufferCursor
со значением по умолчанию 0 - Счетчик, который считает количество считаных символов из Reader за все время жизни класса
StdBufferedReader. Для чего он нужен будет рассказано ниже. Назовем его
charCount
и выберем тип int, или целочисленный тип побольше.
А также отдельный метод, назовем его к примеру fill
с сигнатурой private void fill()
,
задача которого при его вызовы будет полностью заполнить буфер очередной партией символов,
полученных в результате вызова метода read класса
Reader (int charCount = reader.read(buffer, 0, buffer.length)
). А также, если в результате
вызова метода read получили хоть какое-то ненулевое количество символов, то еще в этом же методе
обнуляется курсор буфера (ведь это уже буфер полностью со свежими данными и надо считывать опять
сначала) и обновляет счетчик charCount. В результате метод будет выглядеть так:
private void fill()throws IOException{
int readCharsCount=reader.read(buffer,0,buffer.length);
if(readCharsCount>0){
charCount=readCharsCount;
bufferCursor=0;
}
}
В куске кода int charCount = reader.read(buffer, 0, buffer.length)
мы передаем 3 аргумента:
первый аргумент это массив символов buffer
, в который будут ложиться прочитанные символы из
reader (так как буфер у нас массив, а массив в Java это ссылочный тип, то нам достаточно просто
передать буфер, а метод read сам по полученной ссылке его заполнит), второй это 0
, который
указывает, что заполнять буфер нужно с 0-го индекса, и третий аргумент buffer.length
, который
указывает на то, что мы хотим заполнить, если это возможно, символами сразу весь буфер. Метод read
после своего вызова возвращает количество символов, которые с reader мы по факту прочитали (их может
быть меньше нашего аргумента buffer.length, потому что по факту в потоке всего символов может
оставаться меньше чем у нас размер буфера) и записываем в итоге в переменную charCount
.
Задача этого метода возвращать каждую строчку в виде массива символов char[]. Если обработанная строчка пустая, то соответственно метод возвращать будет просто пустой массив без единого элемента.
Упрощая, можно сказать, что высокоуровневая логика этого метода такая же как я в Пример 1
, а
именно пройтись в for-цикле до того момента пока не встретим символ '\n'. Только мы уже проходимся
не посимвольно через вызов каждый раз reader.read() (иначе где тут буферизация, ради которой и
затевался этот класс), а по буферу buffer. Если буфер пустой на момент вызова метода readLine или
уже мы ранее по нему уже полностью прошлись в поисках '\n', то его нужно заполнить свежими данным с
помощью метода fill()
.
Как и в Примере 1
, нам нужно где-то хранить все те символы (что логически представляют собой
строчку), которые мы перебрали пока не попали на символ '\n' и здесь можно сделать так же как и
в Примере 1
, и использовать StringBuilder, который при нахождении строчки превращает свое
содержимое в массив символов с помощью метода toCharArray(). Или же использовать какой-то свой
массив для промежуточного хранения символов.
В одном из подходов, для реализации этого метода и успешного прохождения тестов, нужно прочитать о работе метода ready в классе Reader, а также подумать в каком положении должен находиться bufferCursor, чтобы это сигнализировало о том, что мы можем продолжать считывать строчки. Его можно реализовать и по другому, например, через собственные поля-флаги, которые вы будете проставлять в методе readLine, когда там становится понятно, что вы прошли все символы из источника. Выбор за вами.
Типичное приложение Java управляет несколькими типами ресурсов, такими как файлы, потоки, сокеты и соединения с базами данных. В нашем случае, наш класс-обертка StdBufferedReader в конструкторе принимает экземпляр класса Reader, с которым и будет непосредственно работать, чтобы считывать символы. Экземпляр класса Reader в свою очередь тоже оборачивает какой-то ресурс, но более низкоуровневый, это может быть файл, сокет для сетевого соединения, или что-то другое, который он читает побайтово или кусками байтов. Этот ресурс "открывается" для чтения, как только мы создаем экземпляр нашего класса StdBufferedReader, потому что мы оборачиваем класс в конструкторе, который где-то глубоко в себе хранит дескриптор на ресурс.
По правилам в Java при работе с ресурсами, мы должны после "открытие" ресурса для работы, позволить
его также "закрывать" после окончания работы. Для этого мы должны заимплементить интерфейс Closable.
Этот интерфейс обязывает нас реализовать метод void close() throws IOException {}
, в котором
нам нужно будет приказать экземпляру абстрактного класса Reader закрыть этот ресурс.
- Подробнее о том как правильно освобождать ресурсы в Java и о конструкции try-with-resources
- Видео о работе try с ресурсами и AutoClosable
Сценарий 1 - Мы читаем поток, который содержит только одну строчку, и размер строчки меньше или равен размеру нашего буфера (buffer.length)
Допустим, мы имеем на входе последовательность символов Howdy, stranger!
И создали пустой
буфер, например, с размером 40. bufferCursor
и charCount
у нас по нулям.
При вызове readLine()
мы должны заполнить буфер, считать с него очередную строчку, и отдать ее
пользователю в виде массива символов. Как нам понять, что нужно сначала заполнить буфер, а потом
искать \n
, а не сразу же его искать \n
? Ведь readLine()
может быть запущен несколько раз и
буфер мог быть заполнен или полностью прочитан и перезаполнен при предыдущих вызовах. При самом
первом вызове readLine()
логично понятно, что нам полюбому нужно будет его сначала заполнить, а
потом искать \n
, а вот как понять это при последующих вызовах? Для этого у нас есть курсор
буфера bufferCursor
, который указывает на текущую позицию в буфере, который нужно передвигать при
каждом считанном символе из буфера, и количество считанных символов charCount
. Мы будем считать,
что буфер нужно заполнить, если bufferCursor >= charCount
.
if(bufferCursor>=charCount){
fill();
}
if(bufferCursor>=charCount){
// Если после вызова fill(), bufferCursor >= charCount всеравно true, значит новых символов в потоке уже нет и можно возвращать пустой массив
return new char[]{};
}
С этого кода проверки вытекает, что при первом вызове метода readLine()
, поля bufferCursor
и charCount
у нас равны 0, поэтому if условие сработает и мы заполним буфер. А при следующих
вызовах уже все будет зависеть от позиции bufferCursor
.
Когда в первый раз мы заполняем буфер с помощью вызова метода fill()
, он будет после вызова
выглядеть следующим образом:
['H', 'o', 'w', 'd', 'y', ',', ' ', 's', 't', 'r', 'a', 'n', 'g', 'e', 'r', '!', '', '', '', '', '', '' , '', '', '' , '', '', '', '', '', '', '', '', '','','','','','','']
То есть в fill()
мы получили какой-то ненулевое количество символов с потока при считывании. Раз
получили ненулевое количество то мы в fill()
также обнулили bufferCursor
, и записали
в charCount
число 16 (так как именно это количество символов было по факту прочитано с потока).
Важно: Прошу заметить, что мы прочли и записали в буфер 16 символов, но не знаем какие именно символы. Ведь это могло быть прочитано 16 подряд символов
\n
или вообще ниодного\n
как в этом частном примере. Но мы этого не знаем до тех пор, пока не пройдемся посимвольно по буферу и только тогда узнаем, что за символы мы имеем в буфере и есть ли там больше одной строчки.
Имея заполненный буфер на charCount
количество, нам теперь нужно по нему пройтись. Пройтись -
означает считать каждый символ в цикле, пока не встретим \n
. Но имея буфер размером 40, мы не
должны пройтись по всем 40 ячейках буфера, а только по заполненным. В данном случае charCount
равен 16. При этом мы должны начинать считывания буффера с позиции bufferCursor
, ведь как раз это
поле и предназначено, чтобы указывать на ячейку буфера, с которой нужно начинать/продолжать
считывание:
StringBuilder s = new StringBuilder();
int i;
for (i = bufferCursor; i < charCount; i++) {
// Ищем '\n'
if (buffer[i] == '\n') {
// нашли '\n'
break;
}
}
bufferCursor = i; // запоминаем позицию курсора буфера
s.append(buffer, i, bufferCursor - i);
Мы пройдемся по буфферу в поисках символа \n
, но в данном Сценарии 1
в итоге его не найдем и
после работы цикла i
будет равно 16 (для этого мы специально int i
вынесли за цикл, чтобы его
значение сохранилось и мы поняли на каком индексе остановились), bufferCursor
так и останется
равно 0. Выходит что пройдя буфер (который был не полностью заполнен, ведь charCount равен 16, а не
был 40), мы не нашли символ перевода строчки, поэтому i
у нас равен charCount
.
Если бы мы прошлись по буферу и встретили бы
\n
, например, приi
равным 10 то сработал бы break и в итоге мы бы получили строчку в буфере, которая начинается с 0 до 10.
В итоге у нас строчка которая начинается с 0 до 16. Мы ее добавляем в StringBuilder и запоминаем
новое положение курсора, ведь мы прочитали буфер на i
позиций.
В данном частном случае, charCount меньше размера буфера, значит можно считать что это конец потока и можем возвращать строчку, ведь мы знаем индекс начала и конца строчки в буфере и останется только ее вернуть
Допустим, мы имеем на входе последовательность символов Howdy, stranger!\nThis is Hauser.
И
создали буфер, например, с размером 40.
После заполнения его с помощью fill, он будет выглядеть следующим образом:
['H', 'o', 'w', 'd', 'y', ',', ' ', 's', 't', 'r', 'a', 'n', 'g', 'e', 'r', '!', '\n', 'T', 'h', 'i', 's', ' ' , 'i', 's', ' ' , 'H', 'a', 'u', 's', 'e', 'r', '.', '', '','','','','','','']
Мы буфер заполнили, но по нему еще не проходили, поэтому bufferCursor равен 0. charCount будет равен
32, так как после выполнения вызова reader.read(buffer, 0, buffer.length)
мы прочтем и положим
в буфер 32 символа.
TODO
Допустим, мы имеем на входе последовательность символов Howdy, stranger!\nThis is Hauser.
И
создали буфер, например, с размером 10. То есть, чтобы прочитать строчку Howdy, stranger!
и отдать
пользователю, нужно будет читать поток как минимум 2 раза.
TODO
- Строчки разделяется друг от друга в разных операционных системах по разному.
Если вы запускаете свою программу в Windows, то новая строчка следует после комбинации \r\n,а в Linux только \n. Поэтому, если вы читаете следующее содержимое файла c двумя строчками (lines)
Howdy, stranger!
This is Hauser.
В Linux это будет одна строка (string) "Howdy, stranger!\nThis is Hauser."
, а в
Windows "Howdy, stranger!\r\nThis is Hauser."
, но с разными разделителями строк.
-
Строка "\n\n\n\n" представляет собой 5 пустых строчек, разделенных между собой \n.
\n
- это символ разделителя строчек, т.е. он говорит нам о том, что если ты встретил символ '\n' - то дальше идет следующая строчка (неважно пустая или нет). -
Если размер файла 0 байт, то это значит что он пустой и там нет вовсе строчек, даже пустых. Если файл содержит только один символ
\n
(размер файла на диске будет 1 байт или больше, зависит от кодировки), то это означает что там 2 пустых строчки, которые разделены символом\n
. Если же в файле какие-то символы, но при этом нет символа перевода строчки, то это означает, что в файле для считывания имеется только одна строчка. -
Наш класс не должен позволять создавать буфер с 0 или отрицательным размером, а также работа класса StdBufferedReader будет невозможна, если нам передадут null вместо экземпляра класса Reader
- Java I/O Fundamentals
- Introduction to BufferedReader in Java
- Understanding the Buffer class in Java
- Эволюция Java на примере чтения строк из файла
- Подробнее об идиоме Resource Acquisition Is Initialization (RAII)
Осталось только закодить!