{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "#### GISC 420 T1 2022\n", "# Object orientation" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%matplotlib inline\n", "\n", "import matplotlib\n", "import matplotlib.pyplot as pyplot\n", "\n", "import math" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We have seen how in Python more complicated data types have associated with them a variety of functions. For example `list`s have `sort()`, `append()` and `reverse()` functions. These are invoked on a variable of type `list` using the 'dot' notation" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "x = [2, 3, 5, 7, 11, 13]\n", "x.sort()\n", "x.reverse()\n", "x.append(17)\n", "x" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "It is more accurate to call a variable of complex type like this an *object*. It turns out we can define our own object types or *classes* and use these when we are developing more complicated programs.\n", "\n", "Spatial objects provide a nice example to illustrate the idea of *objected oriented programming* (OOP). OOP was the thing a few years back. It is now widely used, but not much discussed as such any more, because most modern programming languages provide support for the idea (in other words it is no longer a selling point, as it was when *Java* was the cool new object-oriented language). \n", "\n", "In this notebook, I will show how we can define our own object types in Python, and hopefully convince you of the advantages the approach has to offer in more complex programming tasks" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## The `class` keyword\n", "Like functions, classes must be defined, this time using the `class` keyword. Here is a simple class definition for a `Point` object (by convention we capitalize the names of classes)." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "class Point:\n", " \"\"\"For dealing with points in 2D space\"\"\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This definition is absolutely minimal. All it does it set aside a new class name `Point`. To use it we call `Point()` as if it were a function. The triple quoted (`\"\"\"`) description can be picked up by python aware editing tools to explain the class to a would-be user." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p = Point()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And now we have a `Point` object." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can't do a whole lot with an empty point, so now let's assign some coordinates." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p.x = 3.0\n", "p.y = 4.0\n", "p" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We don't seem to have accomplished very much yet. But, for example, we can now do something like this." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def distance(p1: Point, p2: Point):\n", " return math.hypot(p2.x - p1.x, p2.y - p1.y)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "As opposed to this implementation using tuples:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def distance_tuple(p1, p2):\n", " return math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And now if we make another point object" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "q = Point()\n", "q.x = 7.0\n", "q.y = 1.0" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We have more 'human-readable' code:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "d = distance(p, q)\n", "d" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "It's also worth knowing how to make a copy of an object. We could try the following" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "r = p\n", "r.x, r.y" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "So far so good, but turns out all we have done is to give a new name to the same data:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p.x = 40.0\n", "p.y = 34.0\n", "r.x, r.y" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To make a copy of the `Point` object `p` we use the `copy` module" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import copy\n", "r = copy.copy(p)\n", "p.x = 5.0\n", "p.y = 12.0\n", "p.x, p.y, r.x, r.y" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "So now we actually have two distinct `Point` object instances." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Objects as more than data containers\n", "All we have really seen so far is objects as a convenient way of moving perhaps more than one item of data around in a single 'container'. The object approach becomes much more powerful when we provide ways to manipulate objects that are specific to the things they represent. To do that, in addition to providing them with a name, we provide them with functions, called *methods* suitable for manipulating them in appropriate ways. This involves one more tecnhically-not-an-actual-keyword-but-almost-always-used-in-this-context-word `self`." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "class Point:\n", " \"\"\"Class to represent a point in 2D space\"\"\"\n", " def __init__(self, x=0.0, y=0.0):\n", " self.x = float(x)\n", " self.y = float(y)\n", " return None\n", " \n", " def __str__(self):\n", " return f\"POINT: ({self.x}, {self.y})\" # '(' + str(self.x) + ', ' + str(self.y) + ')'\n", " " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And now when we `print` a `Point` object, it will use the `__str__` method of the class to format it." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p = Point(3, 4)\n", "p.x, p.y" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(p)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now we are getting somewhere. The class definition has expanded to include an initialization method with the special name `__init__()` which expects two parameters `x` and `y`. It also has in the method signature an implied variable `self` which is a reference to the object itself, which must be included as a parameter in all the method definitions of the class.\n", "\n", "This all seems like a bit of a nuisance, but already we are getting some subtle advantages. Notice how the initialization converts the internal attributes of the class `x` and `y` into `float` types. This means that it is now impossible to make a `Point` object with non-float coordinates." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "z = Point('A', 'B')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "That in turn means that we can more confidently write functions that expect `Point` objects as parameters that won't cause unexpected errors.\n", "\n", "We have also defined a method `__str__` which converts our `Point` object to a readable string format. That's how, when we use `print(p)` the `print()` function 'knows' how to print a representation of our object that is readable (it doesn't know... we told it how).\n", "\n", "The `__something__()` methods (double underscore or 'dunder' methods) are built-in operations that we might want to perform on all kinds of objects. For example we might want to determine if two `Points` are equal. Let's make two default `Point` objects and compare them." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p = Point()\n", "q = Point()\n", "p == q" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This is surprising since, if we print `p` and `q` they appear equal" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(p)\n", "print(q)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "But we haven't told Python how to tell if two `Point` objects really are equal. To do this we have to add a `__eq__()` method" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "class Point:\n", " \"\"\"Class to represent a point in 2D space\"\"\"\n", " def __init__(self, x=0.0, y=0.0):\n", " self.x = float(x)\n", " self.y = float(y)\n", " return None\n", " \n", " def __str__(self):\n", " return f'POINT: (' + str(self.x) + ', ' + str(self.y) + ')'\n", " \n", " def __eq__(self, other):\n", " return self.x == other.x and self.y == other.y" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p = Point()\n", "q = Point()\n", "p == q" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "There are a whole host of built-in methods for any new class of objects we define, that we can define in this way if we want to make built-in operations like `>`, `<`, and so on, meaningful. There's a [useful list of many of them here](https://docs.python.org/3/reference/datamodel.html#specialnames). \n", "\n", "It's not particularly obvious what it would mean for a point to be 'greater' than another, but even so it might be useful to be able to sort points (if we had them in a `list`). So here goes..." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "class Point:\n", " \"\"\"Class to represent a point in 2D space\"\"\"\n", " def __init__(self, x=0.0, y=0.0):\n", " self.x = float(x)\n", " self.y = float(y)\n", " return None\n", " \n", " def __str__(self):\n", " return f\"({self.x}, {self.y})\"\n", " \n", " def __eq__(self, other):\n", " return self.x == other.x and self.y == other.y\n", " \n", " def __gt__(self, other):\n", " return self.x + self.y > other.x + other.y\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import random\n", "\n", "pts = []\n", "for i in range(100):\n", " pts.append(Point(random.random(), random.random()))\n", "\n", "fig = pyplot.figure()\n", "ax = fig.add_subplot(111)\n", "ax.set_aspect(\"equal\")\n", "pyplot.plot([p.x for p in pts], [p.y for p in pts])" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pts.sort()\n", "\n", "fig = pyplot.figure()\n", "ax = fig.add_subplot(111)\n", "ax.set_aspect(\"equal\")\n", "pyplot.plot([p.x for p in pts], [p.y for p in pts])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now, because we told python that the sort order of `Point` objects is based on comparing both the x and y coordinates, they are plotted out in a particular order as reflected in the above figure. This is subtle, but powerful stuff. The list `sort` function is making use of the way we have defined `__gt__` (greater than) to sort a list of points. Getting this kind of functionality without an ability to define classes and override the definition of these built-in methods would be really complicated." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Adding our own functionality to a class\n", "Providing standard operations like these to a newly defined class is certainly useful. Even better is providing application specific functions we need to solve our particular programming challenges. Take a look at the next cell." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "class Point:\n", " \"\"\"Class to represent a point in 2D space\"\"\"\n", " def __init__(self, x=0.0, y=0.0):\n", " self.x = float(x)\n", " self.y = float(y)\n", " return None\n", " \n", " # __repr__ is similar to __str__\n", " # but should return a string that could be evaluated as code\n", " def __repr__(self):\n", " return f\"Point(x={self.x}, y={self.y})\"\n", " \n", " # whereas __str__ emphasises human readability\n", " def __str__(self):\n", " return f\"POINT: ({self.x}, {self.y})\"\n", " \n", " def __eq__(self, other):\n", " return self.x == other.x and self.y == other.y\n", " \n", " def __gt__(self, other):\n", " return self.x + self.y > other.x + other.y\n", "\n", " def distance(self, other):\n", " return math.hypot(self.x - other.x, self.y - other.y)\n", " \n", " def manhattan_distance(self, other):\n", " return abs(self.x - other.x) + abs(self.y - other.y)\n", " \n", " def move(self, dx=0.0, dy=0.0):\n", " self.x = self.x + dx\n", " self.y = self.y + dy\n", " return self\n", " \n", " def distance_to_origin(self):\n", " return self.distance(Point())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now we have a `Point` class we can do a fair amount with." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p = Point(3, 4)\n", "q = Point(5, 9)\n", "r = Point(-5, 7)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p.distance(q)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "q.manhattan_distance(p)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p.move(1, 2)\n", "p" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p.distance_to_origin()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Is there an easier way?\n", "Once you have a handle on writing object classes, it's natural to ask if there is a quicker way than making all these `__foo__` functions so as to provide options like printing an instance description comparing values and so on. It turns out (I recently learned... always learn!) that there is, using the built-in `dataclass` construct. The code below replicates the simple `Point` class code I wrote up above, without any of the additional functionality like distance and so on, but with all the core 'data' functionality of storing, sorting etc. It use the `dataclasses` module." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from dataclasses import dataclass\n", "\n", "@dataclass\n", "class Point:\n", " x: float = 0.0\n", " y: float = 0.0" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Try it out:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p = Point()\n", "p" ] }, { "cell_type": "markdown", "metadata": { "tags": [] }, "source": [ "One word of caution is that this won't enforce data types. We can make a `Point` with `x` and `y` attributes set to strings, for example:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "Point(\"a\", \"b\")" ] }, { "cell_type": "markdown", "metadata": { "tags": [] }, "source": [ "This is because python is dynamically typed and the `float` specification is really intended as a _hint_ to human readers not as a declaration of the type. To enforce types you need a whole other package (non-standard) called [`pydantic`](https://pydantic-docs.helpmanual.io/). But let's not worry about that for now.\n", "\n", "You can add functionality to the dataclass `Point` in the usual way, so our class becomes this more compact code:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "tags": [] }, "outputs": [], "source": [ "@dataclass\n", "class Point:\n", " x: float = 0.0\n", " y: float = 0.0\n", "\n", " def distance(self, other):\n", " return math.hypot(self.x - other.x, self.y - other.y)\n", " \n", " def manhattan_distance(self, other):\n", " return math.abs(self.x - other.x) + math.abs(self.y - other.y)\n", " \n", " def move(self, dx=0.0, dy=0.0):\n", " self.x = self.x + dx\n", " self.y = self.y + dy\n", " return self\n", " \n", " def distance_to_origin(self):\n", " return self.distance(Point())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And prove it works:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "p = Point(0, 0)\n", "q = Point(3, 4)\n", "p.distance(q)" ] }, { "cell_type": "markdown", "metadata": { "tags": [] }, "source": [ "I first learned about this functionality from this [mCoding video](https://www.youtube.com/watch?v=vBH6GRJ1REM), a channel I would recommend for all kinds of useful hints." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Now let's make some other objects\n", "We can make more complicated objects from simple one. For brevity, I'll make them using the `dataclass` construct, although note that this approach hits its limits quite quickly if your classes are more than fairly simple data containers.\n", "\n", "Anyway..., a line segment is defined by two points, so let's define one of those" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "@dataclass\n", "class Line_segment:\n", " p1: Point = Point(0, 0)\n", " p2: Point = Point(0, 0)\n", " \n", " def length(self):\n", " return self.p1.distance(self.p2)\n", "\n", " def move(self, dx, dy):\n", " self.p1.move(dx, dy)\n", " self.p2.move(dx, dy)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "A polygon can be defined by a series of points, so let's try that (although note that it is a bit trickier to make this a `dataclass`)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "@dataclass\n", "class Polygon:\n", " points: tuple[Point]\n", " '''A simple polygon object'''\n", "\n", " def perimeter(self):\n", " perimeter = 0\n", " for i, p in enumerate(self.points):\n", " next_point = self.points[(i + 1) % self.num_sides()]\n", " perimeter = perimeter + p.distance(next_point)\n", " return perimeter\n", "\n", " def num_sides(self):\n", " return len(self.points)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And now let's do some things with those classes." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "A = Point(1, 2)\n", "B = Point(5, 2)\n", "C = Point(5, 5)\n", "\n", "AB = Line_segment(A, B)\n", "\n", "ABC = Polygon([A, B, C])" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(f\"{A}\\n{AB}\\n{ABC}\") # this will call the auto-generated str() functions" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "A.distance(B)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "AB.length()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "ABC.perimeter()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Anyway... \n", "Hopefully you get the general idea that we can build elaborate and complicated programs using objects, in such a way that *using* the code remains reasonably easy.\n", "\n", "This approach underpins the complicated functionality we have already been accessing in this course in modules such as `geopandas` and `pyplot`. We don't much care how they do what they do, because we are working with the *public interface* of the classes defined by the module. In fact between versions of the modules, the project maintainers might change *how* the code works, but provided the *Application Programming Interface* (its **API**) remains unchanged, we can continue to use the code unaffected.\n", "\n", "We'll take a look now at `shapely.geometry` to get some idea of this." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## The `shapely.geometry` API\n", "The documentation for `shapely.geometry` is available [here](https://shapely.readthedocs.io/en/latest/). We are most interested in is here: [shapely.readthedocs.io/en/latest/manual.html#geometric-objects](https://shapely.readthedocs.io/en/latest/manual.html#geometric-objects). Let's import this part of the module, also giving it an *alias* to make it less inconvenient to work with." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import shapely.geometry" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "A = shapely.geometry.Point(0, 0)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(A)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "B = shapely.geometry.Point(4, 0)\n", "C = shapely.geometry.Point(4, 3)\n", "print(B)\n", "print(C)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Turns out that `shapely.geometry` can also draw `Point`s" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "B" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Although the drawings aren't very useful. \n", "\n", "Where `shapely.geometry` gets interesting is the geometric manipulations it provides. For example, even with points, we can do this" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "A.buffer(1)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Lets' make a more complicated geometry (the module provides a host of these)." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "ABC = shapely.geometry.LineString([A, B, C])\n", "ABC" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "poly_ABC = shapely.geometry.Polygon(ABC)\n", "poly_ABC" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "poly_ABC.buffer(1)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Another `shapely` module `affinity` provides methods for manipulating geometries in various ways" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import shapely.affinity" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "polys = [poly_ABC]\n", "for angle in range(45, 361, 45):\n", " polys.append(shapely.affinity.rotate(poly_ABC, angle, A))\n", "rotated_triangles = shapely.geometry.GeometryCollection(polys)\n", "rotated_triangles" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And finally:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "rotated_triangles.buffer(0.35)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Have an explore of the documentation and see what other things you can do with this module!" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.7" } }, "nbformat": 4, "nbformat_minor": 4 }