JDD: ReadLine - Kovalevskyi-Academy/AcademyWiki GitHub Wiki

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

streams_everywhere.jpg

Содержание

Введение

Задание состоит в том, чтобы реализовать свой класс 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;
    }
    }

Подробный разбор работы метода fill

В куске кода int charCount = reader.read(buffer, 0, buffer.length) мы передаем 3 аргумента: первый аргумент это массив символов buffer, в который будут ложиться прочитанные символы из reader (так как буфер у нас массив, а массив в Java это ссылочный тип, то нам достаточно просто передать буфер, а метод read сам по полученной ссылке его заполнит), второй это 0, который указывает, что заполнять буфер нужно с 0-го индекса, и третий аргумент buffer.length, который указывает на то, что мы хотим заполнить, если это возможно, символами сразу весь буфер. Метод read после своего вызова возвращает количество символов, которые с reader мы по факту прочитали (их может быть меньше нашего аргумента buffer.length, потому что по факту в потоке всего символов может оставаться меньше чем у нас размер буфера) и записываем в итоге в переменную charCount.

Реализация метода readLine

Задача этого метода возвращать каждую строчку в виде массива символов char[]. Если обработанная строчка пустая, то соответственно метод возвращать будет просто пустой массив без единого элемента.

Упрощая, можно сказать, что высокоуровневая логика этого метода такая же как я в Пример 1, а именно пройтись в for-цикле до того момента пока не встретим символ '\n'. Только мы уже проходимся не посимвольно через вызов каждый раз reader.read() (иначе где тут буферизация, ради которой и затевался этот класс), а по буферу buffer. Если буфер пустой на момент вызова метода readLine или уже мы ранее по нему уже полностью прошлись в поисках '\n', то его нужно заполнить свежими данным с помощью метода fill().

Как и в Примере 1, нам нужно где-то хранить все те символы (что логически представляют собой строчку), которые мы перебрали пока не попали на символ '\n' и здесь можно сделать так же как и в Примере 1, и использовать StringBuilder, который при нахождении строчки превращает свое содержимое в массив символов с помощью метода toCharArray(). Или же использовать какой-то свой массив для промежуточного хранения символов.

Реализация метода hasNext

В одном из подходов, для реализации этого метода и успешного прохождения тестов, нужно прочитать о работе метода ready в классе Reader, а также подумать в каком положении должен находиться bufferCursor, чтобы это сигнализировало о том, что мы можем продолжать считывать строчки. Его можно реализовать и по другому, например, через собственные поля-флаги, которые вы будете проставлять в методе readLine, когда там становится понятно, что вы прошли все символы из источника. Выбор за вами.

Работа с ресурсами и реализация метода close

Типичное приложение Java управляет несколькими типами ресурсов, такими как файлы, потоки, сокеты и соединения с базами данных. В нашем случае, наш класс-обертка StdBufferedReader в конструкторе принимает экземпляр класса Reader, с которым и будет непосредственно работать, чтобы считывать символы. Экземпляр класса Reader в свою очередь тоже оборачивает какой-то ресурс, но более низкоуровневый, это может быть файл, сокет для сетевого соединения, или что-то другое, который он читает побайтово или кусками байтов. Этот ресурс "открывается" для чтения, как только мы создаем экземпляр нашего класса StdBufferedReader, потому что мы оборачиваем класс в конструкторе, который где-то глубоко в себе хранит дескриптор на ресурс.

По правилам в Java при работе с ресурсами, мы должны после "открытие" ресурса для работы, позволить его также "закрывать" после окончания работы. Для этого мы должны заимплементить интерфейс Closable. Этот интерфейс обязывает нас реализовать метод void close() throws IOException {}, в котором нам нужно будет приказать экземпляру абстрактного класса Reader закрыть этот ресурс.

Возможные сценарии при чтении потока в поисках строчки

Сценарий 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 меньше размера буфера, значит можно считать что это конец потока и можем возвращать строчку, ведь мы знаем индекс начала и конца строчки в буфере и останется только ее вернуть

Сценарий 2

Допустим, мы имеем на входе последовательность символов 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

Сценарий 3

Допустим, мы имеем на входе последовательность символов Howdy, stranger!\nThis is Hauser. И создали буфер, например, с размером 10. То есть, чтобы прочитать строчку Howdy, stranger! и отдать пользователю, нужно будет читать поток как минимум 2 раза.

TODO

Дополнительные ньюансы, на которые нужно обратить внимание

  1. Строчки разделяется друг от друга в разных операционных системах по разному.
    Если вы запускаете свою программу в 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.", но с разными разделителями строк.

  1. Строка "\n\n\n\n" представляет собой 5 пустых строчек, разделенных между собой \n. \n - это символ разделителя строчек, т.е. он говорит нам о том, что если ты встретил символ '\n' - то дальше идет следующая строчка (неважно пустая или нет).

  2. Если размер файла 0 байт, то это значит что он пустой и там нет вовсе строчек, даже пустых. Если файл содержит только один символ \n (размер файла на диске будет 1 байт или больше, зависит от кодировки), то это означает что там 2 пустых строчки, которые разделены символом \n. Если же в файле какие-то символы, но при этом нет символа перевода строчки, то это означает, что в файле для считывания имеется только одна строчка.

  3. Наш класс не должен позволять создавать буфер с 0 или отрицательным размером, а также работа класса StdBufferedReader будет невозможна, если нам передадут null вместо экземпляра класса Reader

Дополнительные материалы

Осталось только закодить!

streams_everywhere.jpg

⚠️ **GitHub.com Fallback** ⚠️