Skip to main content

12. Решение задачи

В этой главе мы решим задачу, разберём, как читать json-файлы и записывать их. Также в конце мы напишем несколько unit-тестов

example

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

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

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

Случайные точки

Напишем в классе Task метод добавления случайных точек

    /**
* Добавить случайные точки
*
* @param cnt кол-во случайных точек
*/
public void addRandomPoints(int cnt) {
// если создавать точки с полностью случайными координатами,
// то вероятность того, что они совпадут крайне мала
// поэтому нужно создать вспомогательную малую целочисленную ОСК
// для получения случайной точки мы будем запрашивать случайную
// координату этой решётки (их всего 30х30=900).
// после нам останется только перевести координаты на решётке
// в координаты СК задачи
CoordinateSystem2i addGrid = new CoordinateSystem2i(30, 30);

// повторяем заданное количество раз
for (int i = 0; i < cnt; i++) {
// получаем случайные координаты на решётке
Vector2i gridPos = addGrid.getRandomCoords();
// получаем координаты в СК задачи
Vector2d pos = ownCS.getCoords(gridPos, addGrid);
// сработает примерно в половине случаев
if (ThreadLocalRandom.current().nextBoolean())
addPoint(pos, Point.PointSet.FIRST_SET);
else
addPoint(pos, Point.PointSet.SECOND_SET);
}
}

ThreadLocalRandom.current().nextBoolean() возвращает случайное значение типа boolean.

Теперь добавим необходимые элементы управления в конструкторе панели управления PanelControl:

        ...
// случайное добавление
Label cntLabel = new Label(window, false, backgroundColor, PANEL_PADDING,
6, 7, 0, 4, 1, 1, "Кол-во", true, true);
labels.add(cntLabel);

Input cntField = InputFactory.getInput(window, false, FIELD_BACKGROUND_COLOR, PANEL_PADDING,
6, 7, 1, 4, 2, 1, "5", true,
FIELD_TEXT_COLOR, true);
inputs.add(cntField);

Button addPoints = new Button(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 3, 4, 3, 1, "Добавить\nслучайные точки",
true, true);
addPoints.setOnClick(() -> {
// если числа введены верно
if (!cntField.hasValidIntValue()) {
PanelLog.warning("кол-во точек указано неверно");
} else
PanelRendering.task.addRandomPoints(cntField.intValue());
});
buttons.add(addPoints);
...

Также изменим конструктор панели рисования:

    /**
* Панель управления
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param color цвет подложки
* @param padding отступы
* @param gridWidth кол-во ячеек сетки по ширине
* @param gridHeight кол-во ячеек сетки по высоте
* @param gridX координата в сетке x
* @param gridY координата в сетке y
* @param colspan кол-во колонок, занимаемых панелью
* @param rowspan кол-во строк, занимаемых панелью
*/
public PanelRendering(
Window window, boolean drawBG, int color, int padding, int gridWidth, int gridHeight,
int gridX, int gridY, int colspan, int rowspan
) {
super(window, drawBG, color, padding, gridWidth, gridHeight, gridX, gridY, colspan, rowspan);

// ОСК от [-10.0,-10.0] до [10.0,10.0]
CoordinateSystem2d cs = new CoordinateSystem2d(
new Vector2d(-10.0, -10.0), new Vector2d(10.0, 10.0)
);

// создаём задачу без точек
task = new Task(cs, new ArrayList<>());
// добавляем в нё 10 случайных
task.addRandomPoints(10);
}

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

example

Создайте новый коммит random points и отправьте его на сервер.

Интерфейс управления

Но для начала добавим метод очистки clear() в классе Task. В нём мы просто удаляем все добавленные в задачу точки.

    /**
* Очистить задачу
*/
public void clear() {
points.clear();
}

Теперь напишем заглушку для метода решения задачи и сброса. Заглушка - это метод, который ничего не делает или выводят сообщение о том, что он были вызван.

    /**
* Решить задачу
*/
public void solve() {
PanelLog.warning("Вызван метод solve()\n Пока что решения нет");
}
/**
* Отмена решения задачи
*/
public void cancel() {

}

Теперь в панели управления PanelControl напишем метод отмены решения

   /**
* Сброс решения задачи
*/
private void cancelTask() {
PanelRendering.task.cancel();
// Задаём новый текст кнопке решения
solve.text = "Решить";
}

Т.к. у кнопки решения нужно будет менять текст во время работы программы, то необходимо хранить её дополнительно в поле solve класса PanelControl:

    /**
* Кнопка "решить"
*/
private final Button solve;

Поэтому нам остаётся только создать недостающие элементы управления в конструкторе PanelControl:

        ...
// управление
Button load = new Button(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 0, 5, 3, 1, "Загрузить",
true, true);
load.setOnClick(() -> {
PanelRendering.load();
cancelTask();
});
buttons.add(load);

Button save = new Button(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 3, 5, 3, 1, "Сохранить",
true, true);
save.setOnClick(PanelRendering::save);
buttons.add(save);

Button clear = new Button(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 0, 6, 3, 1, "Очистить",
true, true);
clear.setOnClick(() -> PanelRendering.task.clear());
buttons.add(clear);

solve = new Button(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 3, 6, 3, 1, "Решить",
true, true);
solve.setOnClick(() -> {
PanelRendering.task.solve();
});
buttons.add(solve);
...

Запустим программу

example

Создайте новый коммит control beginning и отправьте его на сервер.

Работа с файлами

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

Выбор формата файла хранения - задача, в целом, творческая. Иногда бывает удобно хранить данные в табличном виде. С этим лучше всего справляется формат csv. Если нам нужно хранить древовидные структуры данных, то в зависимости от сложившейся традиции в той или иной сфере, используют либо xml-образные разметки, в том числе HTML, либо json.

На мой взгляд, json формат выглядит эстетичнее, а ручное заполнение древовидных структур требует банально меньшего количества символов. Однако при достаточно большом количестве данных такой формат становится плохо читаемым. Тут лучше использовать xml.

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

В java уже написана готовая библиотека для работы с json форматом. Она умеет по файлу сразу строить объект класса. Мы её уже загрузили в самом начале с помощью зависимости maven. Библиотека называется Jackson.

Правда, нам нужно добавить несколько json-аннотаций, т.е. команд, начинающихся со знака @.

У него уже прописаны чтение и запись из файла. Но всё же разберём классический способ чтения и записи в файл.

В папке src/resources/ нужно создать файл conf.json

example

В java есть готовые классы, которые позволят работать с данными из файлов также, как раньше мы работали с консолью

Информация

Это - демонстрационный код, копировать его никуда не нужно

package com.company;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintStream;
import java.util.Scanner;

public class Main{

public static void main(String[] args) {
try {
// создаём сканер
Scanner in = new Scanner(new File("INPUT.TXT"));
// создаём принтер
PrintStream out = new PrintStream("OUTPUT.TXT");

// дальше работаем с ними так же, как раньше с консолью
int a = in.nextInt();
out.println(a * 2);
} catch (FileNotFoundException e) {
e.printStackTrace();
}

}
}

Настроим теперь автоматические чтение и запись в формате json.

Сам по себе формат json чем-то похож на Cи-образный язык. Т.е. язык, построенный на основе синтаксиса языка C.

Объект json представляет собой словарь. Словарь - это набор пар "ключ":"значение".

{
"ключ1": "значение1",
"ключ2": "значение2"
}

Любое значение может быть само словарём

{
"ключ1": "значение1",
"ключ2": {
"ключ3":"значение3",
"ключ4":"значение4"
}
}

Или списком:

{
"ключ1": "значение1",
"ключ2": {
"ключ3":"значение3",
"ключ4":"значение4"
},
"ключ5": [
"значение5",
"значение6",
"значение7"
]
}

Общая идея структуры любого json-файла такая: весь файл - это один большой json-словарь, в котором описана вся структура.

Пропишем теперь сохранение объекта задачи средствами jackson в панели рисования PanelRendering:

    /**
* Сохранить файл
*/
public static void save() {
String path = "src/main/resources/conf.json";
try {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.writeValue(new File(path), task);
PanelLog.success("Файл " + path + " успешно сохранён");
} catch (IOException e) {
PanelLog.error("не получилось записать файл \n" + e);
}
}

Попробуем теперь сохранить файл, нажав на кнопку "Сохранить"

example

В лог будет выведена ошибка с текстом о том, что для класса app.Task не определена сериализация. Это происходит из-за того, что для сложных структур, например, для класса задачи, нужно явно прописать с помощью аннотаций, как именно сериализовать наш объект.

Сериализация - это свёртывание большого объекта к набору простых, определяющих его полей. Этот простой набор можно куда-то направить, например, в json-файл, либо другому процессу, программе. Правда, при передаче данных другой программе проще всего сформировать json-строку и отправить уже её.

Первую аннотацию нам нужно добавить к объявлению самого класса задачи


/**
* Класс задачи
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, property = "@class")
public class Task {
...

Она помогает jackson понять, что объекты Task нужно воспринимать именно как объекты класса.

Теперь нам нужно слегка переписать конструктор

    /**
* Задача
*
* @param ownCS СК задачи
* @param points массив точек
*/
@JsonCreator
public Task(
@JsonProperty("ownCS") CoordinateSystem2d ownCS,
@JsonProperty("points") ArrayList<Point> points
) {
this.ownCS = ownCS;
this.points = points;
}

Аннотация @JsonCreator говорит jackson, что для создания объектов класса из json-файла должен использоваться именно этот конструктор.

В параметрах необходимо для каждого аргумента указать, как он называется в json-структуре. Для повышения универсальности поля объекта и ключи в json-файле могут не совпадать. Поэтому название и указывается явно. Просто в нашем случае они совпали.

Если теперь мы снова нажмём на кнопку сохранить, то сохранение выполнится без ошибок. Правда, в самом файле conf.json пока что ничего нет, кроме указания, объект какого именно класса сохранён.

example

{"@class":"app.Task"}
Обратите внимание

Если вы будете создавать вручную json-файлы, не забывайте указывать, объект какого класса создаётся, если этого требует логика вашего класса.

По умолчанию jackson сохраняет в файл только публичные поля и те поля, у которых прописаны геттеры. Подробно о них говорилось в теории по ООП.

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

    /**
* Получить тип мира
*
* @return тип мира
*/
public CoordinateSystem2d getOwnCS() {
return ownCS;
}

/**
* Получить название мира
*
* @return название мира
*/
public ArrayList<Point> getPoints() {
return points;
}

благодаря библиотеке lombook (которую мы подключили в первом блоке) достаточно добавить аннотацию @Getter у соответствующих полей класса Task:

    /**
* Вещественная система координат задачи
*/
@Getter
private final CoordinateSystem2d ownCS;
/**
* Список точек
*/
@Getter
private final ArrayList<Point> points;

Если теперь мы снова сохраним точки, то получим файл с одной строкой:

example

Выполним автофармотирование файла с помощью команды Ctrl+Alt+L - получим относительно красивый вывод json

example

Посмотрим сначала на запись СК. Jackson принял методы getRandomCoords() и getSize() у целочисленной ОСК ownCS за используемые при сериализации геттеры.

Чтобы сообщить jackson, что при сериализации эти методы нужно пропустить, достаточно добавить при объявлении этого метода аннотацию @JsonIgnore.

Аннотируем, т.е. снабдим аннотацией, методы в классе CoordinateSystem2i:

    /**
* Получить случайные координаты внутри СК
*
* @return случайные координаты внутри СК
*/
@JsonIgnore
public Vector2i getRandomCoords() {
return Vector2i.rand(min, max);
}
...
/**
* Получить размер СК
*
* @return размер СК
*/
@JsonIgnore
public Vector2i getSize() {
return size;
}

Также пропишем конструктор для jackson'а:

    /**
* Конструктор ограниченной двумерной целочисленной системы координат
*
* @param min минимальные координаты
* @param max максимальные координаты
*/
@JsonCreator
public CoordinateSystem2i(@JsonProperty("min") Vector2i min, @JsonProperty("max") Vector2i max) {
this(min.x, min.y, max.x - min.x, max.y - min.x);
}

Тоже самое сделаем с классом CoordinateSystem2d

    /**
* Получить случайные координаты внутри СК
*
* @return случайные координаты внутри СК
*/
@JsonIgnore
public Vector2d getRandomCoords() {
return Vector2d.rand(min, max);
}
...
/**
* Получить размер СК
*
* @return размер СК
*/
@JsonIgnore
public Vector2d getSize() {
return size;
}

Не забудьте переписать конструктор

   /**
* Конструктор ограниченной двумерной вещественной системы координат
*
* @param min минимальные координаты
* @param max максимальные координаты
*/
@JsonCreator
public CoordinateSystem2d(@JsonProperty("min") Vector2d min, @JsonProperty("max") Vector2d max) {
this(min.x, min.y, max.x - min.x, max.y - min.y);
}

Теперь в объекте СК останутся только нужные нам данные.

example

Теперь нам осталось убрать ключи "color" и "setName" из объектов точек. Для этого нам нужно просто аннотировать методы getColor() и getSetName() в классе Point с помощью @JsonIgnore:

    ...
/**
* Получить цвет точки по её множеству
*
* @return цвет точки
*/
@JsonIgnore
public int getColor() {
return switch (pointSet) {
case FIRST_SET -> Misc.getColor(0xCC, 0x00, 0x00, 0xFF);
case SECOND_SET -> Misc.getColor(0xCC, 0x00, 0xFF, 0x0);
};
}
...
/**
* Получить название множества
*
* @return название множества
*/
@JsonIgnore
public String getSetName() {
return switch (pointSet) {
case FIRST_SET -> "Первое множество";
case SECOND_SET -> "Второе множество";
};
}
...

Теперь в json записывается только нужная информация

example

Пропишем загрузку файла в панели рисования PanelRendering

    /**
* Загружаем из файла
*
* @param path путь к файлу
*/
public static void loadFromFile(String path) {
// создаём загрузчик JSON
ObjectMapper objectMapper = new ObjectMapper();
try {
// считываем систему координат
task = objectMapper.readValue(new File(path), Task.class);
PanelLog.success("Файл " + path + " успешно загружен");
} catch (IOException e) {
PanelLog.error("Не получилось прочитать файл " + path + "\n" + e);
}
}

Также перепишем обработку кнопки загрузки в ней же

    /**
* Загрузить файл
*/
public static void load() {
String path = "src/main/resources/conf.json";
PanelLog.info("load from " + path);
loadFromFile(path);
}

Если теперь ма запустим загрузку из файла, то получим ошибку

example

Это происходит из-за того, что мы не определили конструктор точки для jackson. Перепишем конструктор:

    /**
* Конструктор точки
*
* @param pos положение точки
* @param setType множество, которому она принадлежит
*/
@JsonCreator
public Point(@JsonProperty("pos") Vector2d pos, @JsonProperty("setType") PointSet setType) {
this.pos = pos;
this.pointSet = setType;
}

Теперь загрузка работает корректно

example

Создайте новый коммит json works и отправьте его на сервер.

Кнопка решения

Из основного функционала нам осталось решить саму задачу.

Для начала добавим флаг, решена ли задача в классе Taks:

    /**
* Флаг, решена ли задача
*/
private boolean solved;

Если мы добавляем точку, то флаг должен сбрасываться, поэтому добавим сброс флага в метод добавления

   /**
* Добавить точку
*
* @param pos положение
* @param pointSet множество
*/
public void addPoint(Vector2d pos, Point.PointSet pointSet) {
solved = false;
Point newPoint = new Point(pos, pointSet);
points.add(newPoint);
PanelLog.info("точка " + newPoint + " добавлена в " + newPoint.getSetName());
}

Точно также флаг нужно сбросить при очистке

    /**
* Очистить задачу
*/
public void clear() {
points.clear();
solved = false;
}

В методе solve будем выставлять этот флаг в значение true

    /**
* Решить задачу
*/
public void solve() {
solved = true;
PanelLog.warning("Вызван метод solve()\n Пока что решения нет");
}

Для отмены решения допишем метод cancel():

    /**
* Отмена решения задачи
*/
public void cancel() {
solved = false;
}

Также напишем метод проверки, решена ли задача

    /**
* проверка, решена ли задача
*
* @return флаг
*/
public boolean isSolved() {
return solved;
}

Теперь настроим клик по кнопке решения так, чтобы если задача не была решена, то текст менялся на Сбросить. Для этого нужно добавить в тело обработчика нажатия кнопки solve в конструкторе панели управления PanelControl

        ...
solve.setOnClick(() -> {
if (!PanelRendering.task.isSolved()) {
PanelRendering.task.solve();
solve.text = "Сбросить";
} else {
cancelTask();
}
window.requestFrame();
});
...

Кнопка теперь работает и меняет свой текст в зависимости о того, решена задача или нет

example

Создайте новый коммит solve button и отправьте его на сервер.

Решение

Для того чтобы решить задачу, в классе Task добавим динамические массивы точек, принадлежащих пересечению и точек, принадлежащих разности множеств

    /**
* Список точек в пересечении
*/
@Getter
@JsonIgnore
private final ArrayList<Point> crossed;
/**
* Список точек в разности
*/
@Getter
@JsonIgnore
private final ArrayList<Point> single;
Обратите внимание

Эти два новых поля снабжены аннотациями @Getter и @JsonIgnore. Первая - из библиотеки lombook, перед компиляцей автоматически добавляет геттеры для всех полей, снабжённых этой аннотацией. Вторая сообщает java, что при сохранении класса в json-файл соответствующее пол

Инициализируем теперь их в конструкторе

    this.crossed = new ArrayList<>();
this.single = new ArrayList<>();

Добавим теперь цвета для пересечения и разности в классе Colors

   /**
* Цвет пересечения
*/
public static final int CROSSED_COLOR = Misc.getColor(200, 0, 255, 255);
/**
* Цвет разности
*/
public static final int SUBTRACTED_COLOR = Misc.getColor(200, 255, 255, 0);

Теперь изменим логику определения цвет пера в методе рисования в классе задачи Task:

    /**
* Рисование задачи
*
* @param canvas область рисования
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// Сохраняем последнюю СК
lastWindowCS = windowCS;

canvas.save();
// создаём перо
try (var paint = new Paint()) {
for (Point p : points) {
if (!solved) {
paint.setColor(p.getColor());
} else {
if (crossed.contains(p))
paint.setColor(CROSSED_COLOR);
else
paint.setColor(SUBTRACTED_COLOR);
}
// y-координату разворачиваем, потому что у СК окна ось y направлена вниз,
// а в классическом представлении - вверх
Vector2i windowPos = windowCS.getCoords(p.pos.x, p.pos.y, ownCS);
// рисуем точку
canvas.drawRect(Rect.makeXYWH(windowPos.x - POINT_SIZE, windowPos.y - POINT_SIZE, POINT_SIZE * 2, POINT_SIZE * 2), paint);
}
}
canvas.restore();
}

Снова перепишем обработку клика по кнопке задачи в конструкторе PanelControl.

        ...
solve.setOnClick(() -> {
if (!PanelRendering.task.isSolved()) {
PanelRendering.task.solve();
String s = "Задача решена\n" +
"Пересечений: " + PanelRendering.task.getCrossed().size() / 2 + "\n" +
"Отдельных точек: " + PanelRendering.task.getSingle().size();
PanelLog.success(s);
solve.text = "Сбросить";
} else {
cancelTask();
}
window.requestFrame();
});
...

Допишем теперь метод решения задачи в классе Task:

    /**
* Решить задачу
*/
public void solve() {
// очищаем списки
crossed.clear();
single.clear();

// перебираем пары точек
for (int i = 0; i < points.size(); i++) {
for (int j = i + 1; j < points.size(); j++) {
// сохраняем точки
Point a = points.get(i);
Point b = points.get(j);
// если точки совпадают по положению
if (a.pos.equals(b.pos) && !a.pointSet.equals(b.pointSet)) {
if (!crossed.contains(a)){
crossed.add(a);
crossed.add(b);
}
}
}
}

/// добавляем вс
for (Point point : points)
if (!crossed.contains(point))
single.add(point);

// задача решена
solved = true;
}
Обратите внимание

Для проверки, совпадают ли точки, я использую метод equals(), определённый в вещественном векторе equals(), он в свою очередь стандартным методом Double.compare() проверяет на равенство обе координаты.

Попробуем снова решить задачу

example

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

Для начала заполним вручную файл src/main/resouces/conf.json:

{
"@class": "app.Task",
"ownCS": {
"min": {
"x": -10.0,
"y": -10.0
},
"max": {
"x": 10.0,
"y": 10.0
}
},
"points": [
{
"pos": {
"x": -1.0,
"y": 1.0
},
"setType": "FIRST_SET"
},
{
"pos": {
"x": 1.0,
"y": 1.0
},
"setType": "FIRST_SET"
},
{
"pos": {
"x": -1.0,
"y": 1.0
},
"setType": "SECOND_SET"
},
{
"pos": {
"x": -1.0,
"y": 2.0
},
"setType": "SECOND_SET"
}
]
}
вещественные числа

Обязательно дописывайте в конце вещественных значений .0. Если вы оставите целое значение, например, "x": -1 то при загрузке файла получите ошибку.

Теперь, если загрузить файл, то решение будет наглядным

example

Создайте новый коммит solve demo и отправьте его на сервер.

Задание

Пример готового проекта лежит здесь. Для выполнения первого задания Fork от него делать запрещается.

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

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

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

  1. random points - пропишите добавление случайных точек по клику мыши
  2. control beginning - добавьте новую панель и пропишите в ней прототипы кнопок с заглушками в качестве обработчика клика
  3. json works - пропишите сохранение и загрузку из json-файла
  4. solve button - пропишите работу кнопки решения задачи так, чтобы она по клику меняла текст: Решить<->Очистить
  5. solve demo - пропишите решение задачи по входным данным

example

Ссылку на github репозиторий необходимо отправить в поле ввода задания на сайте mdl.

Ссылка на контест