Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Попов Захар #257

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open

Conversation

BlizPerfect
Copy link

@Folleach

Черновой вариант решения второй домашней работы.

Примеры работ с разными параметрами:
1.

  • Число прямоугольников = 100;
  • Ширина прямоугольников от 30 до 70 пикселей;
  • Высота прямоугольников от 20 до 50 пикселей.
    circularCloudLayouter
  • Число прямоугольников = 10000;
  • Ширина прямоугольников от 30 до 70 пикселей;
  • Высота прямоугольников от 20 до 50 пикселей.
    circularCloudLayouter
  • Число прямоугольников = 100;
  • Ширина прямоугольников от 30 до 700 пикселей;
  • Высота прямоугольников от 20 до 500 пикселей.
    circularCloudLayouter
  • Число прямоугольников = 10;
  • Ширина прямоугольников от 30 до 700 пикселей;
  • Высота прямоугольников от 20 до 500 пикселей.
    circularCloudLayouter
  • Число прямоугольников = 10;
  • Ширина прямоугольников от 30 до 700 пикселей;
  • Высота прямоугольников от 20 до 500 пикселей.
    circularCloudLayouter

Принцип расположения прямоугольников:
Прямоугольники ставятся на окружности увеличивающегося радиуса с центром в точке, совпадающей с центром первого поставленного прямоугольника.

@BlizPerfect BlizPerfect changed the title Черновой вариант решения второго домашнего задания. Попов Захар Nov 13, 2024
Copy link

@Folleach Folleach left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Резюмируя

В целом для предпроверки - нормально, но код явно ещё нужнается в причёсывании. Хотелось бы видеть больше декомпозии на отдельные компонены, чтобы гига-методы с уровнем вложенности 4 стали проще!

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

И не забывай обращать внимание на разнообразные предупреждения IDE, они помогают улучшить общую красоту кода ^_^

Про git могу сказать, круто, что теперь ветка отдельная, но название не несёт в себе какой-либо полезной информации. Хотелось бы, чтобы оно было понятнее.
На практике в названии ветки часто содержиться номер задачи или никнейм автора, а потом пара ключевых слов, о чём ветка, чтобы можно было их различать:

  • task-1234/circular-visualizer
  • BlizPerfect/render-engine

cs/TagsCloudVisualization/CircularCloudLayouterTests.cs Outdated Show resolved Hide resolved
cs/TagsCloudVisualization/CircularCloudLayouterTests.cs Outdated Show resolved Hide resolved
cs/TagsCloudVisualization/CircularCloudLayouterWorker.cs Outdated Show resolved Hide resolved
cs/TagsCloudVisualization/CircularCloudLayouterTests.cs Outdated Show resolved Hide resolved
cs/TagsCloudVisualization/CircularCloudLayouter.cs Outdated Show resolved Hide resolved
cs/TagsCloudVisualization/CircularCloudLayouter.cs Outdated Show resolved Hide resolved
cs/TagsCloudVisualization/CircularCloudLayouterTests.cs Outdated Show resolved Hide resolved
cs/TagsCloudVisualization/CircularCloudLayouter.cs Outdated Show resolved Hide resolved
var result = new Point[360];
var step = 1;

for (int angle = 0; angle < 360; angle += step)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Почему в некоторых местах используется var, а в некоторых явное задание типа?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Это мне когда студия сама подсказывает код, я там забываю поменять иногда тип данных, но всегда сам использую var)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Понял. Но как и писал выше - хотелось бы видеть единобразный стиль во всём коде

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я полностью с Вами согласен - тоже считаю, что всё должно быть единообразно.

Конкретно с var в циклах у меня забавная ситуация:
При решении задач на Leetcode я сначала руками всё писал через var, потом начал принимать подсказки студии, но менять int в цикле на var, но в итоге сдался и перестал это замечать)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Возможно Visual Studio можно настраить, чтобы она давала правильные подскази? :)

Не знаю как в ней, но в rider для этого есть настраиваемые live templates
image

В vs code это называется snippets
В visual studio вроде тоже snippets

BlizPerfect and others added 10 commits November 17, 2024 15:07
Класс CircularCloudLayouterWorker был переписан как статичный для избежания создания экземпляра класса.
1. Добавлен интерфейс ICircularCloudLayouter.
2. Класс CircularCloudLayouter теперь реализует интерфейс ICircularCloudLayouter.
1. Рефакторинг класса CircularCloudLayouter.
2. Выделение сущности Circle, отвечающей за полчение координат на окружности.
1. Добавление новых тестов.
2. Общий рефакторинг кода.
Comment on lines 95 to 103
[TestCase(0.55, ExpectedResult = true)]
[Repeat(10)]
public bool ShouldPlaceRectanglesTightly(double tight)
{
var boundingBoxSize = GetBoundingBoxSize();
var boundingBoxArea = boundingBoxSize.Width * boundingBoxSize.Height;
var filledArea = _rectangles.Sum(r => r.Width * r.Height);
return ((double)filledArea / boundingBoxArea) >= tight;
}
Copy link

@Folleach Folleach Nov 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ой, тут, кажется, тоже квадрат пройдёт тест

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Видимо этот тест был убран в угоду ShouldPlaceCenterOfMassOfRectanglesNearCenter

resolved

Comment on lines +23 to +27
[TestCase(null, ExpectedResult = true)]
[TestCase(100, ExpectedResult = true)]
public bool Draw_CalculatesImageSizeCorrectly(int? padding)
{
var correctPadding = padding ?? _defaultPadding;
Copy link

@Folleach Folleach Nov 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Bitmap Draw(IList<Rectangle> rectangles, int? paddingPerSide = null)

Но в тестах это лишнее. Можно null в TestCase заменить на 10 - будет нагляднее

Comment on lines 12 to 13
private Circle _arrangementСircle = new Circle(center);
private Random _random = new Random();
Copy link

@Folleach Folleach Nov 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Если ссылка никогда не модифицируется - можно смело делать её readonly

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В других местах тоже стоит исправлять комментарии

1. Тесты ShouldPlaceRectanglesInCircle и ShouldPlaceRectanglesTightly были заменены на более точный тест ShouldPlaceRectanglesInCircle, обьединяющий их функциональность.
2. ShouldPlaceRectanglesNearCenter переименован в ShouldPlaceCenterOfMassOfRectanglesNearCenter, так как предыдущее название вводило в заблуждение по функциональности теста.
3. Рефакторинг тестов.

private bool[,] GetOccupancyGrid(int gridSize, double maxRadius, double step)
{
var result = new bool[gridSize, gridSize];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Массив байт 1000x1000 это гарантированное попадание в Large Object Heap

If an object is greater than or equal to 85,000 bytes in size, it's considered a large object

Большие объекты дольше создаются, дольше удаляются (причём только при сборке 2-го поколения) и занимают много места в оперативной памяти. Подробнее можно почитать по ссылке выше.

Поэтому рекомендуется по возможности их избегать.

В данном случае, что было бы, если облако тегов содержало 2 диаметрально противоположных прямоугольнка на дальних координатах?
Им может банально не хватить оперативной памяти компьютера, чтобы "закрасить" этот grid.

Альтернативный вариант, который тут можно было реализовать: подсчёт площадей.
Мы можем легко посчитать сумму всех площадей прямоугольников: O(n) по времени, O(1) по памяти.
А также можем найти радиус описанной окружности всех прямоугольников. Что позволит посчитать площадь.
Отношение этих двух чисел будет показывать коэффициент плотности располагаемых прямоугольников.

Math.Abs(rectangle.X - point.X), Math.Abs(rectangle.X + rectangle.Width - point.X));
var dy = Math.Max(
Math.Abs(rectangle.Y - point.Y), Math.Abs(rectangle.Y + rectangle.Height - point.Y));
return Math.Sqrt(dx * dx + dy * dy);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Функция встречается трижды в коде - хороший кандидат на выделение в метод

@Folleach
Copy link

Folleach commented Nov 20, 2024

Так как дедлайн по этой задаче уже прошёл, а тест ShouldPlaceRectanglesInCircle получился слишком сложный, предлагаю его разобрать.

[TestCase(0.7, 1000)]
[Repeat(10)]
public void ShouldPlaceRectanglesInCircle(double expectedCoverageRatio, int gridSize)
{
    var maxRadius = _rectangles.Max(r => r.GetMaxDistanceFromPointToRectangleAngles(_center));
    var step = (2 * maxRadius) / gridSize;

    var occupancyGrid = GetOccupancyGrid(gridSize, maxRadius, step);

    var actualCoverageRatio = GetOccupancyGridRatio(occupancyGrid, maxRadius, step);
    actualCoverageRatio.Should().BeGreaterThanOrEqualTo(expectedCoverageRatio);
}

Выглядит легко, если особо не присматриваться :)
Начнём с конца:

1 строка

actualCoverageRatio.Should().BeGreaterThanOrEqualTo(expectedCoverageRatio);

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

2 строка

var actualCoverageRatio = GetOccupancyGridRatio(occupancyGrid, maxRadius, step);

Уже интереснее, реализация такова:

private double GetOccupancyGridRatio(bool[,] occupancyGrid, double maxRadius, double step)
{
    var totalCellsInsideCircle = 0;
    var coveredCellsInsideCircle = 0;
    for (var x = 0; x < occupancyGrid.GetLength(0); x++)
    {
        for (var y = 0; y < occupancyGrid.GetLength(0); y++)
        {
            var cellCenterX = x * step - maxRadius + _center.X;
            var cellCenterY = y * step - maxRadius + _center.Y;
            var distance = Math.Sqrt(
                Math.Pow(cellCenterX - _center.X, 2) + Math.Pow(cellCenterY - _center.Y, 2));
            if (distance > maxRadius)
            {
                continue;
            }
            totalCellsInsideCircle += 1;
            if (occupancyGrid[x, y])
            {
                coveredCellsInsideCircle += 1;
            }
        }
    }
    return (double)coveredCellsInsideCircle / totalCellsInsideCircle;
}

Я бы сказал: "тут слишком много ответственности", это нарушает Single Responsibility Principle (S из SOLID), потому что:

  • Считает количество закрашенных клеток
  • Вырузает необходимую фигуру
  • Делает дополнительные математические вычисления

Сразу скажу, я не особо вдавался в подробности что такое step и для чего он нужен, буду делать рассказ отталкиваясь от того, что его нет.

1. Вырез сетки

Итак, у нас есть полотно, закрашенное чёрным и белым цветом: bool[,]
На этом полотне нас интересуют только те точки, которые попадают в определённую фигуру, конкретно - круг.

Мы могли бы написать функцию, которая наше чёрно-белое полотно превратит в такое-же чёрно-белое полотно, но с альфа-каналом (как png без фона). Где прозрачный фон это null.
Благодаря чему будет возможность считать точки не с фильтрацией по какой-то конкретной функции, а только тех, где точка не равна null.

Реализация

public interface IShape
{
    bool Inside(Point point);
}

public static class GridExtensions
{
    public static bool?[,] Carve(this bool[,] source, IShape shape)
    {
        var grid = new bool?[source.GetLength(0), source.GetLength(1)];
        for (var i = 0; i < source.GetLength(0); i++)
        {
            for (var j = 0; j < source.GetLength(1); j++)
            {
                if (shape.Inside(new Point(i, j)))
                    grid[i, j] = source[i, j];
            }
        }

        return grid;
    }
}

IShape - абстрактная фигура.
Абстрагирование фигуры позволяет расширять функционал, но не изменять уже написанный код.
Это буква O из SOLID

Сегодня мы строим облако тегов в виде круга, а завтра захочется построить его в виде треугольника.

Соблюли ли мы тут SRP (Single Responsibility Principle)?
Вроде да, потому что единственная причина, по которой захотим изменить функцию Carve - изменение алгоритма вырезания фигур.

Как думаешь, какие ещё принципы из SOLID тут явно соблюдаются?

2. Математические вычисления

Теперь у нас есть функция вырезания фигур. Следующим этапом нам нужно завести фигуру Circle.
Для того, чтобы наша функция Inside могла сказать, находится ли точка внутри круга, достаточно знать центр фигуры и желаемый радиус окружности.
Реализуем:

public class Circle(Point center, double radius) : IShape
{
    public bool Inside(Point point) => Math.Sqrt(Math.Pow(center.X - point.X, 2) + Math.Pow(center.Y - point.Y, 2)) <= radius;
}

Хм... Будто бы тут что-то не так...
Действительно, функция нахождения расстояния между двумя точками хорошо извества и может быть полезна не только нашему кругу. Тем более, каждый раз писать с нуля математический функции, как по мне, практически эквивалентно написанию своей реализации шифрования - ошибиться очень просто.
Так-что выделяем:

public static class PointExtensions
{
    public static double DistanceTo(this Point first, Point second)
        => Math.Sqrt(Math.Pow(first.X - second.X, 2) + Math.Pow(first.Y - second.Y, 2));
}

Теперь и Circle выглядит куда приятнее

public class Circle(Point center, double radius) : IShape
{
    public bool Inside(Point point) => point.DistanceTo(center) <= radius;
}

3. Подсчёт точек

Теперь, когда у нас есть вырезанная фигура, подсчёт общего количества точек не составит труда.

var grid = occupancyGrid.Carve(new Circle(_center, maxRadius));
var total = grid.Cast<bool?>().Count(x => x != null); // пиксель не пустой
var colored = grid.Cast<bool?>().Count(x => x == true); // пиксель закрашен

3 строка

var occupancyGrid = GetOccupancyGrid(gridSize, maxRadius, step);

Ага, понятно: создаёт изначальное полотно на котором окрашивает прямоугольники.
Разделено на методы я бы даже сказал +- хорошо, но реализация слишком сложна.

private (int start, int end) GetGridIndexesInterval(
    int rectangleStartValue,
    int rectangleCorrespondingSize,
    double maxRadius,
    double step)
{
    var start = (int)((rectangleStartValue - _center.X + maxRadius) / step);
    var end = (int)((rectangleStartValue + rectangleCorrespondingSize - _center.X + maxRadius) / step);
    return (start, end);
}

private bool[,] GetOccupancyGrid(int gridSize, double maxRadius, double step)
{
    var result = new bool[gridSize, gridSize];
    foreach (var rect in _rectangles)
    {
        var xInterval = GetGridIndexesInterval(rect.X, rect.Width, maxRadius, step);
        var yInterval = GetGridIndexesInterval(rect.Y, rect.Height, maxRadius, step);
        for (var x = xInterval.start; x <= xInterval.end; x++)
        {
            for (var y = yInterval.start; y <= yInterval.end; y++)
            {
                result[x, y] = true;
            }
        }
    }
    return result;
}

1. Keep it simple

Начнём с GetGridIndexesInterval, его идея: вернуть интервал в одном измерении. Имея точку на отрезке и длину отрезка.
Это правда замечательно, что метод реализован для одного измерения, так как построить в дальнейшем интервал на двумерной плоскости или трёхмерном пространстве не составит труда.

Но его можно сделать проще:

private (int start, int end) GetIndexesInterval(int position, int length)
    => (position, position + length - 1);

Соответственно maxRadius и step параметры будут не нужны.

private bool[,] GetOccupancyGrid(int gridSize)
{
    var result = new bool[gridSize, gridSize];
    foreach (var rect in _rectangles)
    {
        var xInterval = GetIndexesInterval(rect.X, rect.Width);
        var yInterval = GetIndexesInterval(rect.Y, rect.Height);
        for (var x = xInterval.start; x <= xInterval.end; x++)
        {
            for (var y = yInterval.start; y <= yInterval.end; y++)
            {
                result[x, y] = true;
            }
        }
    }
    return result;
}

2. Yet another decomposition

Если присмотреться к методу GetOccupancyGrid, можно заметить две задачи, которые он выполняет:

  1. Закрашивает область прямоугольника на полотне
  2. Закрашивает области для всех прямоугольников

Это вполне себе отдельные методы, которые могут быть разделены:

Метод, позволяющий закрасить прямоугольник в любом двумерном масиве

public static T[,] Fill<T>(this T[,] source, Rectangle rectangle, T color)
{
    // не создаю копию массива в целях производительности
    // поэтому функция не чистая, а хотелось бы
    var xInterval = GetIndexesInterval(rectangle.X, rectangle.Width);
    var yInterval = GetIndexesInterval(rectangle.Y, rectangle.Height);
    for (var i = xInterval.start; i <= xInterval.end; i++)
    {
        for (var j = yInterval.start; j <= yInterval.end; j++)
            source[i, j] = color;
    }

    return source;
}

private static (int start, int end) GetIndexesInterval(int position, int length) => (position, position + length);

Метод Carve, кстати, можно также реализовать с использованием generic типа и "цвета"

GetOccupancyGrid, закрашивающий все прямоугольники

private bool?[,] GetOccupancyGrid(int gridSize)
{
    var result = new bool?[gridSize, gridSize];
    result.Fill(new Rectangle(0, 0, gridSize, gridSize), false);
    return _rectangles.Aggregate(result, (current, rectangle) => current.Fill(rectangle, true));
}

4-5 строка

var maxRadius = _rectangles.Max(r => r.GetMaxDistanceFromPointToRectangleAngles(_center));
var step = (2 * maxRadius) / gridSize;

Тут, в целом, всё понятно.
step - уже не нужен
maxRadius - радиус описанной окружности.

Круто, что вычисление максимального расстояния до точек вынесено в отдельный метод GetMaxDistanceFromPointToRectangleAngles.

Пока поверим, что он возвращает правильные значения. Но было бы здорово видеть на него тесты!

Рефакторим тест

[TestCase(0.7, 1000)]
[Repeat(10)]
public void ShouldPlaceRectanglesInCircle(double expectedCoverageRatio, int gridSize)
{
    var maxRadius = _rectangles.Max(r => r.GetMaxDistanceFromPointToRectangleAngles(_center));
    var grid = new bool?[gridSize, gridSize].Fill(new Rectangle(0, 0, gridSize, gridSize), false);
    grid = _rectangles
        .Aggregate(grid, (current, rectangle) => current.Fill(rectangle, true))
        .Carve(new Circle(_center, maxRadius));

    var total = grid.Cast<bool?>().Count(x => x != null);
    var covered = grid.Cast<bool?>().Count(x => x == true);
    var actual = (double)covered / total;

    actual.Should().BeGreaterThanOrEqualTo(expectedCoverageRatio);
}

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

Что-ж, кажется, мы затевали рефакторинг, чтобы вносить изменения было проще? Самое время попробовать.

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

var boundingBox = _rectangles.GetBoundingBox();

grid = _rectangles
    .Select(x => x.Map(boundingBox, new Rectangle(0, 0, gridSize, gridSize)))
    .Aggregate(grid, (current, rectangle) => current.Fill(rectangle, true))
    .Carve(new Circle(_center, maxRadius));

Реализации таковы:

public static Rectangle GetBoundingBox(this IList<Rectangle> rectangles)
{
    return new Rectangle(
        rectangles.Min(r => r.X),
        rectangles.Min(r => r.Y),
        rectangles.Max(r => r.X + r.Width),
        rectangles.Max(r => r.Y + r.Height));
}

public static Rectangle Map(this Rectangle rectangle, Rectangle from, Rectangle to)
{
    return new Rectangle(
        new Point(
            rectangle.X.ConvertInterval(from.X, from.Width, to.X, to.Width),
            rectangle.Y.ConvertInterval(from.Y, from.Height, to.Y, to.Height)
        ),
        new Size(
            (int)(rectangle.Width * GetStretch(from.X, from.Width, to.X, to.Width)),
            (int)(rectangle.Height * GetStretch(from.Y, from.Height, to.Y, to.Height))
        )
    );
}

private static int ConvertInterval(this int value, int oldMin, int oldMax, int newMin, int newMax)
{
    var oldRange = oldMax - oldMin;
    var newRange = newMax - newMin; 
    return (value - oldMin) * newRange / oldRange + newMin;
}

private static double GetStretch(int oldMin, int oldMax, int newMin, int newMax)
{
    var oldRange = oldMax - oldMin;
    var newRange = newMax - newMin;
    return (double)newRange / oldRange;
}

Финально тест выглядит так

public void ShouldPlaceRectanglesInCircle(double expectedCoverageRatio, int gridSize)
{
    var maxRadius = _rectangles.Max(r => r.GetMaxDistanceFromPointToRectangleAngles(_center));
    var grid = new bool?[gridSize, gridSize].Fill(new Rectangle(0, 0, gridSize, gridSize), false);
    var boundingBox = _rectangles.GetBoundingBox();

    grid = _rectangles
        .Select(x => x.Map(boundingBox, new Rectangle(0, 0, gridSize, gridSize)))
        .Aggregate(grid, (current, rectangle) => current.Fill(rectangle, true))
        .Carve(new Circle(_center, maxRadius));

    var total = grid.Cast<bool?>().Count(x => x != null);
    var covered = grid.Cast<bool?>().Count(x => x == true);
    var actual = (double)covered / total;

    actual.Should().BeGreaterThanOrEqualTo(expectedCoverageRatio);
}

У нас получилось исправить баг, не задевая внутренности написаных ранее методов. Должно быть, это хорошо.
А также, как по мне, метод стал более читаем: "создаём матрицу размером gridSize, все прямоугольники выравниваем в эту матрицу, закрашиваем выровненные прямоугольники, вырезаем на матрице круг и считаем соотношение закрашенных пикселей к общему количеству.
Ещё обрати внимание, сколько появилось отдельно стоящих методов, их все можно переиспользовать в других местах кода и тестировать независимо!

У меня на самом деле тут где-то в преобразованиях прямоугольников есть баг, но идея этого текста показать, как можно декомпозировать запутанную логику в отдельные компоненты, поэтому баг позволю себе вынести за скобки.

В идеале, как уже говорил, тут можно было посчитать площади без матриц.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants