Към съдържанието
wannacode

Данни във файлове

Проблем

Текст, а трябват числа

Складът изнася продуктите си във файл. Всеки ред е един продукт: име, цена и наличност, разделени със запетаи. Първият ред казва какво е във всяка колона:

products.csv
Име,Цена,Наличност
Хляб,1.20,30
Мляко,2.10,18
Кафе на зърна,18.90,12

Вече можеш да прочетеш файла. Но за програмата редът е само текст:

String line = "Хляб,1.20,30";
System.out.println(line.length());
System.out.println(line.charAt(5));
Изход
12
1

Тя не знае, че 1.20 е цена, а 30 е брой. За да сметнеш колко струва стоката, трябва да превърнеш всеки ред в нещо с полета: в обект Product.

Идеята

CSV: ред към обект

Такъв файл се казва CSV (comma-separated values, „стойности, разделени със запетая“). Чете се от Excel, от бази данни, от всяка програма. Един ред става обект в три стъпки: split, превръщане на всяка част в нужния тип, конструктор.

Най-удобно е това да е статичен метод в самия клас:
class Product {
String name;
double price;
int stock;
Product(String name, double price, int stock) {
this.name = name;
this.price = price;
this.stock = stock;
}
static Product fromCsv(String line) {
String[] f = line.split(",");
if (f.length != 3) {
throw new IllegalArgumentException("очаквам 3 колони, а са " + f.length);
}
return new Product(f[0].strip(), Double.parseDouble(f[1].strip()), Integer.parseInt(f[2].strip()));
}
String toCsv() {
return name + "," + price + "," + stock;
}
}
Product p = Product.fromCsv("Кафе на зърна,18.90,12");
System.out.printf("%s: %.2f € x %d = %.2f €%n", p.name, p.price, p.stock, p.price * p.stock);
Изход
Кафе на зърна: 18.90 € x 12 = 226.80 €

fromCsv е единственото място, което знае как изглежда редът. Ако форматът се промени, променяш само него. Проверката за броя на колоните хвърля IllegalArgumentException с ясно съобщение, вместо програмата да спре по-късно с индекс извън масива.

Какво прави fromCsv с реда, стъпка по стъпка, с тялото му, разписано в main:

String line = "Кафе на зърна,18.90,12";
String[] f = line.split(",");
String name = f[0];
double price = Double.parseDouble(f[1]);
int stock = Integer.parseInt(f[2]);
Product p = new Product(name, price, stock);
System.out.println(p.name + ": " + p.price + " € x " + p.stock);
System.out.printf("%.2f €%n", p.price * p.stock);
Стъпка по стъпка, с думи
  1. Редът от файла е един текст. За програмата 18.90 в него е само четири знака.
  2. split реже реда при всяка запетая: масив от три текста.
  3. Името си остава текст.
  4. parseDouble превръща текста "18.90" в числото 18.9. С него вече може да се смята.
  5. parseInt прави от "12" цялото число 12.
  6. Конструкторът слага трите стойности в полетата на нов Product, а p сочи към него.
  7. p.price се печата като 18.9: нулата в края беше само в текста.
  8. Цената по наличността: вече е сметка с числа.

Виж го

Целият файл

Зареждаш всички продукти в масив и смяташ стойността на стоката:
Path file = Path.of("products.csv");
try {
Files.writeString(file, "Име,Цена,Наличност\nХляб,1.20,30\nМляко,2.10,18\nКафе на зърна,18.90,12\n");
List<String> lines = Files.readAllLines(file);
Product[] products = new Product[lines.size() - 1];
int count = 0;
boolean header = true;
for (String line : lines) {
if (header) {
header = false;
continue;
}
products[count] = Product.fromCsv(line);
count++;
}
double value = 0;
for (int i = 0; i < count; i++) {
value += products[i].price * products[i].stock;
}
System.out.printf("%d продукта, стока за %.2f €%n", count, value);
} catch (IOException e) {
System.out.println("Не мога да прочета " + file + ": " + e.getMessage());
}
Изход
3 продукта, стока за 300.60 €
  • Масивът е с един елемент по-малко от редовете, защото първият ред е заглавен.
  • header е true само за първото завъртане. Тогава редът се пропуска с continue.
  • count брои колко продукта са сложени в масива досега и е индексът за следващия.

В модул „Колекции“ ще видиш списък, който расте сам, и броенето с count няма да ти трябва.

Идеята

Грешни редове

Файловете ги пишат хора и други програми. Рано или късно идва ред без колона, дума вместо число или празен ред:

products.csv
Име,Цена,Наличност
Хляб,1.20,30
Мляко,2.10
Кафе,седем,4
Чай,3.50,25

Една грешка не трябва да спира зареждането на всичко останало. Всеки ред е в свой try, а съобщението казва номера на реда, за да може някой да го поправи:

Path file = Path.of("products.csv");
try {
Files.writeString(file, "Име,Цена,Наличност\nХляб,1.20,30\nМляко,2.10\nКафе,седем,4\n\nЧай,3.50,25\n");
List<String> lines = Files.readAllLines(file);
Product[] products = new Product[lines.size()];
int count = 0;
int row = 0;
for (String line : lines) {
row++;
if (row == 1 || line.isBlank()) {
continue;
}
try {
products[count] = Product.fromCsv(line);
count++;
} catch (NumberFormatException e) {
System.out.println("Ред " + row + ": грешно число (" + e.getMessage() + ")");
} catch (IllegalArgumentException e) {
System.out.println("Ред " + row + ": " + e.getMessage());
}
}
System.out.println("Заредени: " + count);
} catch (IOException e) {
System.out.println("Не мога да прочета " + file + ": " + e.getMessage());
}
Изход
Ред 3: очаквам 3 колони, а са 2
Ред 4: грешно число (For input string: "седем")
Заредени: 2
  • Празните редове се пропускат с isBlank().
  • NumberFormatException е наследник на IllegalArgumentException, затова е пръв. Иначе общият catch би хванал и него.
  • Програмата казва „Заредени: 2“ и продължава с тях.

Пробвай

Празни колони

При първия ред цената е празна, при втория наличността:

Какво ще отпечата програмата?

System.out.println("Кафе,,4".split(",").length);
System.out.println("Кафе,7.50,".split(",").length);
System.out.println("Кафе,7.50,".split(",", -1).length);
Отговор

Идеята

Обекти към CSV

Обратната посока е метод, който сглобява реда:

String toCsv() {
return name + "," + price + "," + stock;
}

Записваш заглавния ред и после по един ред за всеки обект:

Product[] stock = { new Product("Хляб", 1.2, 30), new Product("Мляко", 2.1, 18) };
Path file = Path.of("stock.csv");
try {
try (BufferedWriter out = Files.newBufferedWriter(file)) {
out.write("Име,Цена,Наличност");
out.newLine();
for (Product p : stock) {
out.write(p.toCsv());
out.newLine();
}
}
System.out.print(Files.readString(file));
} catch (IOException e) {
System.out.println("Не мога да запиша " + file + ": " + e.getMessage());
}
Изход
Име,Цена,Наличност
Хляб,1.2,30
Мляко,2.1,18

fromCsv може да прочете този файл обратно. Записът и четенето трябва да ползват един и същ формат.

Внимавай, ако записваш цените със String.format. Той следва езиковите настройки на компютъра, а на български десетичният знак е запетая:

String bg = String.format(Locale.of("bg"), "%s,%.2f,%d", "Хляб", 1.2, 30);
System.out.println(bg + " -> " + bg.split(",").length + " колони");
String root = String.format(Locale.ROOT, "%s,%.2f,%d", "Хляб", 1.2, 30);
System.out.println(root + " -> " + root.split(",").length + " колони");
Изход
Хляб,1,20,30 -> 4 колони
Хляб,1.20,30 -> 3 колони

1,20 става две колони и редовете се разместват. За файлове, които друга програма ще чете, подай Locale.ROOT (java.util.Locale). Той дава точка винаги, на всеки компютър.

Пробвай

Намери грешката

Програмата трябва да зареди трите продукта от products.csv, а спира още на първия ред:

Грешка
Exception in thread "main" java.lang.NumberFormatException: For input string: "Цена"

Избери реда с грешката.

Идеята

Колони по име

Друга програма изнася същите данни, но с колоните в друг ред и с една повече. Ако пишеш f[1] за цената, при нов формат ще четеш грешна колона и няма да разбереш. По-сигурно е да намериш колоната по името ѝ в заглавния ред:

static int column(String[] header, String name) {
for (int i = 0; i < header.length; i++) {
if (header[i].strip().equalsIgnoreCase(name)) {
return i;
}
}
throw new IllegalArgumentException("Няма колона " + name);
}
Path file = Path.of("export.csv");
try {
Files.writeString(file, "Наличност,Име,Код,Цена\n30,Хляб,1001,1.20\n18,Мляко,1002,2.10\n");
int nameCol = -1;
int priceCol = -1;
for (String line : Files.readAllLines(file)) {
String[] f = line.split(",");
if (nameCol == -1) {
nameCol = column(f, "име");
priceCol = column(f, "цена");
continue;
}
System.out.println(f[nameCol] + ": " + f[priceCol] + " €");
}
} catch (IOException e) {
System.out.println(e.getMessage());
}
Изход
Хляб: 1.20 €
Мляко: 2.10 €

Първото завъртане само намира къде са колоните. След това всеки ред се чете по тези индекси. Ако колоната я няма, column хвърля изключение веднага, вместо да чете грешни данни.

По-точно, за любопитните
  • Запетая в стойността. „Сирене, краве“ съдържа запетая. В CSV такава стойност се огражда с кавички: "Сирене, краве",9.80,5, а кавичка вътре се пише двойна: "". Тогава split(",") вече не стига. В истински проекти се ползва библиотека като OpenCSV или Apache Commons CSV, която знае всички правила.
  • Друг разделител. Excel на компютър с български настройки често записва CSV с ;, именно защото запетаята е десетичният знак.
  • Сериализация. Java може да запише цял обект във файл с ObjectOutputStream, ако класът имплементира Serializable. Файлът тогава е двоичен: не се чете от човек и се чупи при промяна на класа. Текстовите формати като CSV и JSON са по-предвидими и затова се ползват повече.

Упражнение

Данни, които оживяват

Реши задачите в редактора си. Програмата първо сама създава входния файл.

1. Анализ на CSV. Създай people.csv със съдържанието по-долу. Отпечатай средната възраст (целочислено деление), кой е най-възрастен и колко души има от всеки град, в реда, в който градовете се появяват. Градовете пази в масив String[], а броевете в int[] със същия размер.

people.csv
Име,Възраст,Град
Иван,34,София
Мария,28,Пловдив
Георги,41,София
Елена,25,Варна
Изход
Средна възраст: 32
Най-възрастен: Георги, 41
Брой хора от София: 2
Брой хора от Пловдив: 1
Брой хора от Варна: 1

2. Запис на героя. Клас Hero с име, ниво, живот и злато. Метод save(Path file) записва героя като редове ключ=стойност, а статичен метод load(Path file) създава нов Hero от такъв файл. Създай „Арин“ с ниво 6, 100 живот и 120 злато, вдигни му нивото с 1, махни 15 живот, дай му 220 злато, запиши го, отпечатай файла и го зареди обратно.

Изход
name=Арин
level=7
health=85
gold=340
Заредих: Арин, ниво 7, живот 85, злато 340

3. Дублиран текст. Създай text.txt с текста по-долу. Раздели го на изречения по точката (и интервалите след нея) и отпечатай всяко изречение, което се среща повече от веднъж, по веднъж.

text.txt
Това е текст. Това също е текст. Това е текст.
Изход
Дублиран текст: "Това е текст."

Още задачи

  • Най-скъпият продукт. Зареди products.csv от урока и отпечатай продукта с най-висока цена и колко продукта имат наличност под 20.
  • Поправени редове. Зареди файла с грешните редове и запиши верните в products-clean.csv, а грешните, с номера и причината, в rejected.txt.
  • Отчет за продажбите. Файл sales.csv с колони Дата,Продукт,Брой,Цена. Редовете са подредени по дата. Отпечатай оборота за всеки ден със String.format в колони.

Тест

Провери се

1. Какво е първият ред на повечето CSV файлове?
2. Защо catch (NumberFormatException e) трябва да е преди catch (IllegalArgumentException e)?
3. Колко е "Кафе,7.50,".split(",").length?
4. Записваш цени със String.format("%.2f", price) на компютър с български настройки. Какво ще стане със CSV файла?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·