Homework 1 Notes

1. Introduction

This quarter, we're diving into a large-scale project focused on 2D Vector Graphics. We'll develop a basic vector graphics library under the namespace VG. Due to academic honesty policies, I won't be able to make the repository public but will document the key aspects of the project here.

In this homework, we'll be implementing two classes:

  • VG::Point: This class will serve as a data structure for storing 2D coordinates.
  • VG::VectorGraphic: This class will hold a collection of VG::Points and will also specify whether the shape is closed.

2. Implementing the VG::Point Class

2.1 Introduction

While VG::Point initially appears to be a straightforward data structure for storing 2D coordinates, the assignment specification encourages us to think about future extensibility. It notes that 2D shapes can be decomposed into points, lines, and curves. Importantly, a point in this system is not just a 2D coordinate; it could also possess "handles" to help define curves. Given this added complexity, a class structure is more suited for VG::Point than a simple struct would be.

2.2 Implementation

Currently, VG::Point supports basic operations like ==, !=, <<, and getters.

#pragma once

#include <iosfwd>

namespace VG
{
    class Point
    {
    public:
        constexpr Point(int x, int y) : myX{ x }, myY{ y } { }

        Point(const Point& other) = default;
        Point(Point&& other) = default;
        ~Point() = default;

        Point& operator=(const Point&) = default;
        Point& operator=(Point&&) = default;

        bool operator==(const Point&) const;
        bool operator!=(const Point&) const;

        constexpr int getX() const { return myX; }
        constexpr int getY() const { return myY; }

    private:
        int myX{ 0 };
        int myY{ 0 };
    };

    std::ostream& operator<<(std::ostream& os, const Point& p);
}

Several things to note:

  • The "handles" mentioned in the assignment spec are not implemented.
  • The <iosfwd> header is used instead of <iostream> to avoid including the entire <iostream> header. This is a good practice to avoid unnecessary compilation time.
  • The "Rule of Five" is used to implement the copy and move constructors and assignment operators.
  • The constexpr keyword is used to mark the constructor and getters as constexpr to allow the compiler to evaluate them at compile time.

Point.cpp contains the implementation of the Point class. It is trivial and not worth mentioning here.


3. Implementing the VG::VectorGraphic Class

3.1 Introduction

A VectorGraphic is a collection of VG::Points with option specifying if the shape is closed or not. "Close" here simply mean drawing an extra line from the last point to the first point. In the later parts of the project, we will also implement properties like fill and stroke, etc.

3.2 Design Considerations

  • Should VG::Point be passed by value or by reference?: The common wisdom tells us to keep it simple: if an object acts just like a simple type (like a number), then make it a value object. Otherwise, treat it as a reference object. Since we're considering extensibility (like the possibility of adding "handles" for curves in the future), using references for Point objects fits well here. And since we are using const references, we don't really need to worry about the performance penalty of copying objects.

  • Handling Open and Closed Shapes: The design needs to accommodate both open and closed shapes. Instead of using boolean accessors like setOpen(true) or setClosed(false), which can be confusing and reveal implementation details, it's better to use separate methods for this purpose, such as openShape() and closeShape(). However, they could mislead clients into thinking they need to pass in a shape object as a parameter, which is a naming issue that needs to be considered. To allow clients to check the state of a shape, providing methods like isOpen() and isClosed() is beneficial.

  • Reporting Bounds and Horizontal Alignment: While vector graphics can theoretically have various complex bounds, in practical usage, most systems prefer a simple rectangular representation aligned horizontally. This rectangular "box" is usually defined by its width and height. This simplification serves most client needs, like memory allocation for drawing, where the client would multiply the width by the height to create a buffer. This approach also avoids complications that could arise from other definitions of a rectangle, focusing instead on the most commonly understood version with horizontal alignment.

3.3 Header File

In light of the design considerations above, the VG::VectorGraphic class is implemented as follows:

#pragma once

#include "Point.h"
#include <vector>

namespace VG
{
    using Points = std::vector<Point>;

    class VectorGraphic
    {
    public:
        VectorGraphic();

        VectorGraphic(const VectorGraphic& other) = default;
        VectorGraphic(VectorGraphic&& other) = default;
        ~VectorGraphic() = default;

        VectorGraphic& operator=(const VectorGraphic&) = default;
        VectorGraphic& operator=(VectorGraphic&&) = default;

        bool operator==(const VectorGraphic& other) const;
        bool operator!=(const VectorGraphic& other) const;

        void addPoint(const Point& p);      // pass by const reference
        void addPoint(Point&& p);           // pass by rvalue reference
        void removePoint(const Point& p);
        void erasePoint(int index);

        void openShape();
        void closeShape();

        bool isOpen() const;
        bool isClosed() const;

        int getWidth() const;
        int getHeight() const;

        size_t getPointCount() const;
        const Point& getPoint(int index) const;

    private:
        Points myPath;

        enum class ShapeStyle { Open, Closed } myShapeStyle;
    };

}

Several things to note:

  • The Points type is defined as an alias for std::vector<Point>. This is a good practice to avoid typing out the full type name and to make the code more readable.
  • The ShapeStyle enum is defined inside the class to avoid polluting the global namespace.
  • GetPointCount return type is size_t instead of int because size_t is the type used by the standard library for sizes and indices.

3.4 Source File

I won't show the full implementation of VectorGraphic.cpp here, but I'll highlight some interesting functions:

  • VectorGraphic::addPoint(Point &&p):
void VectorGraphic::addPoint(Point &&p)
{
    myPath.emplace_back(p);
    // trivially-copyable type, don't need to std::move
}

The Point is passed by r-value reference, which allows us to move the Point into the vector instead of copying it. However, since Point is a trivially-copyable type, the compiler will automatically optimize the copy into a move, so we don't need to explicitly call std::move(). Also, if you forget, r-value reference parameters are those that are passed like this:

myVG.addPoint(Point{ 1, 2 });

R-value is a new feature in C++11 so that we can access temporary objects by reference. This is useful for avoiding unnecessary copies.

  • VectorGraphic::removePoint(const Point& p):
void VectorGraphic::removePoint(const Point &p)
{
    // myPath.erase(std::remove_if(myPath.begin(), myPath.end(),
    //                             [&p](const auto &point)
    //                             { return point == p; }),
    //              myPath.end());
    // A more modern and concise way to remove elements from a container:
    std::erase_if(myPath,
                    [&p](const auto &point)
                    { return point == p; });
}

As said in the comments, the std::erase_if() function is a more modern and concise way to remove elements from a container. It is available in C++20. We used to have to use std::remove_if() in conjunction with std::erase() to remove elements from a container. The std::erase_if() function combines these two steps into one.

  • VectorGraphic::getWidth() and VectorGraphic::getHeight():
int VectorGraphic::getWidth() const
{
    if (myPath.empty())
    {
        return 0;
    }

    auto minMaxX = std::minmax_element(myPath.begin(), myPath.end(),
                                        [](const auto &lhs, const auto &rhs)
                                        { return lhs.getX() < rhs.getX(); });

    return minMaxX.second->getX() - minMaxX.first->getX();
}

int VectorGraphic::getHeight() const
{
    if (myPath.empty())
    {
        return 0;
    }

    auto minMaxY = std::minmax_element(myPath.begin(), myPath.end(),
                                        [](const auto &lhs, const auto &rhs)
                                        { return lhs.getY() < rhs.getY(); });

    return minMaxY.second->getY() - minMaxY.first->getY();
}

These two functions are implemented using the std::minmax_element() algorithm. This algorithm returns a pair of iterators pointing to the smallest and largest elements in the range. We can then use the getX() and getY() methods to get the x and y coordinates of the smallest and largest points. The width and height are then calculated by subtracting the x and y coordinates of the smallest point from the largest point.