This guide introduces classes, demonstrating how they can be used to represent custom reuseable structures within our code
In this guide, you will learn how to extend the functionality of Python through custom classes, which allow you to represent an 'object' of any kind through the combination of local variables which store information about a particular instance of the object, as well as a set of functions that define what the object can do.
While functions make our code more efficient and reuseble, they still assume that the code is meant to be executed a single time. This is typical in design applications, where code is used to create relatively short 'scripts' that are meant to be run when needed to accomplish a specific task. This type of programming is commonly referred to as procedural programming, since each passage of code defines a single procedure that is meant to be executed once and runs top to bottom until it is complete.
For more complex modern programs, however, the program does not have a single specific behavior, but runs continuously and changes based on a user's input. Instead of just using conditionals, loops, and functions to control the execution of the code, such programs rely on objects that define the different functionalities of the program. As the program runs, these objects can interact with each other and change their behavior based on user input and other events that are happening within the program.
This type of programming is called object-oriented programming or OOP, and is one of the major foundations of modern programming. The key to OOP are classes, which define the behavior of these objects. Although we will not get too deep into OOP within this sequence, creating custom classes can be very useful for defining custom objects with specific behaviors even if we are using a mostly procedural approach.
Like functions, classes are structures in our program that contain sections of code that define functionality that can be reused in our main script. However, while functions define a single set of procedures, classes can define a set of related functions that define the behavior of a particular object, as well as a set of local variables that keep track of the state of each instance of that object. Functions defined within a class are commonly called methods, while variables that are local to that class are referred to as its properties. Together, properties and methods define the 'behavior' of the object, and dictate how it interacts with other objects in the programming 'environment'.
Like functions, classes are structures that define certain functionality, but don't do anything on their own. Just like a function needs to be called within the main script to run its code, we can use class definions to create instances of the class, which we can then use within our script.
Let's think about this in everyday terms. For an animal, an example of a method might be 'running'. Lots of things can run, so the definition of running as a function would be general, and would not necessarily relate to who is doing the running. On the other hand, an example of a class might be 'Dog', which would have an instance of the 'running' method, as well as other methods related to being a dog such as 'eating' and 'barking'. It would also have a set of properties for storing information about a given dog, such as its age, breed, or weight. Another class might be 'Human', which would store different properties, and would have its own particular version of methods such as 'running' and 'eating' (but hopefully not 'barking').
You may not realize it, but we've already used classes and methods throughout this sequence. With the exception of a few basic data types such as int
, float
, and bool
, all data types we've worked with including str (string)
, list
, and dict (dictionary)
are actually implemented as classes that come built into Python. When we created strings
or lists
we've actually been creating instances of these classes, and when we've run operations on them (for example calling .append()
to add an item to a list), we've actually been using the methods implemented for those classes.
Now let's look at how we can define custom classes in Python and use them within our scripts to create instances of the objects they define.
Let's define a very basic class to see how it works. We will use an example of a counter, which will store a value, and increment that value based on user requests:
class Counter:
count = 0
def add_to_counter(self, input_value):
self.count += input_value
def get_count(self):
return self.count
Notice we are again using the +=
shorthand to increment the value of the instance's count
property by the input value. To use this class, we first need to create an instance of it, which we will store in a variable just like any other piece of data:
my_counter = Counter()
Once we create an instance of a class, we can run that instance's methods, and query it's internal properties. Note that the general class definition is only a construct. All properties defined within the class only apply to a particular instance, and the methods can only be run as they relate to that instance. For example:
my_counter.add_to_counter(2)
print(my_counter.get_count())
Right away, you will notice a few differences between how we define functions and classes. First of all, no variables are passed on the first line of the definition since the class
keyword only defines the overall structure of the class. After the first line you will find a list of variables which define the local parameters of that class, and keep track of data for individual instances. After this you will have a collection of local methods (remember 'methods' are simply functions that belong to instances of a class) that define the class functionality. These methods are defined just like functions, except you see that the first input is always the keyword 'self'. This is a reference to the instance that is calling the method, and is always passed as the first input into each method within a class. This allows you to query the local parameters of the instance, as you can see us doing with the 'count' variable.
To call a method within a class, you use the name of the variable that is storing the instance, and use the dot (.
) notation to call the method. The dot is basically your way into the instance and all of its data and functionality. It is also possible to use the dot syntax to query the local parameters of the class instance. For example, if we want to find the value of my_counter
's count
variable, we can just ask it by typing:
my_counter.count
However, this is discouraged because it reveals the true name of the local parameters to the end user of the class. In a production environment this would pose severe security risks, but it is considered bad practice even in private uses. Instead, you are encouraged to create special 'accessor' methods to pull parameter values from the instance, as we have done with the get_count()
method in the example above. Another advantage of this practice (which is called encapsulation) is that the code is easier to maintain. You are free to make any changes within the class definition, including changing the names of the local parameters and what they do. As long as you maintain the accessor functions and they return the expected result, you do not have to update anything in the main code that uses the class.
As far as naming classes goes, you can follow the same rule as naming variables or functions, however the standard practice is to capitalize every word, including the first one.
Finally, in the example above every instance we make of the Counter will start the counter at 0. However, what if we want to specify what this count should be when we make an instance of the class? For this we can implement the __init__()
method (those are two underscores on each side of init
):
class Counter:
def __init__(self, initial_value):
self.count = initial_value
def add_to_counter(self, input_value):
self.count += input_value
def get_count(self):
return self.count
Now when we create a new instance of the Counter class we can pass in a starting value for the count
property:
my_counter = Counter(10)
my_counter.add_to_counter(2)
#this should now return 12
print(my_counter.get_count())
When the class instance is initialized, it will automatically run the __init__()
method, which will utilize any variable passed into it during initialization. __init__()
is one of several provided "magic" methods that classes can implement to achieve different functionality. To implement one of these special methods you just have to define it using the special name with two underscores on either side. The rest of these are beyond the scope of this module, but you can find a more thorough description of these, as well as other aspects of classes, in the Python documentation.
The following exercise will give you hands on experience with writing and using classes by applying them to the attractor point example developed in the previous exercise. This time, we will extend the definition to use a custom class that defines all the logic for our grid points, including calculating distance to an attractor point as well as calculating the radius and creating the circle. If you don't have the file from the last exercise handy you can download it here:
Let's start by implementing a new class called Cell
which will represent each position in our grid. This class will now be responsible for everything that needs to happen at each grid location, including constructing a point, measuring the distance to another point, calculating a radius, and finally creating a circle. To implement a basic version of the Cell
class, replace the code in the Python
component with:
import Rhino.Geometry as rh
class Cell:
def __init__(self, x, y):
self.x = x
self.y = y
def get_pt(self):
return rh.Point3d(self.x, self.y, 0)
def calc_radius(dist, factor=1):
if dist < 2:
radius = 0.25
else:
radius = 0.5
return radius * factor
cells = []
for x in range(int(num_points)):
for y in range(int(num_points)):
cell = Cell(x, y, attractor)
cells.append(cell)
circles = []
points = []
for cell in cells:
point = cell.get_pt()
points.append(point)
dist = point.DistanceTo(attractor)
for factor in range(1, 10):
circle = rh.Circle(point, calc_radius(dist, factor * 0.2))
circles.append(circle)
This code adds a definition for a new class called Cell
using the class
keyword. Within this class definition we define two methods using the def
keyword: the special __init__()
method which will run when a new instance of the class is created, and a method called get_pt()
which creates a point at the position represented by the cell. Remember that in Python, methods are simply functions that we define within a class definition. These behave exactly like standard functions, except we can only call them from a specific instance of a class.
When we call a method, it also gets a reference to the instance it's being called from, including any properties being stored by the instance. This is why we must always include self
as the first input parameter to any method. This variable will then give us access to the instance calling the method within the body of the method. In the code above, you can see how we use the self
variable so store the x and y coordinates as local properties of each instance of our Cell class as it's created. We also use the self
variable to access those parameters from the instance within the get_pt()
method.
Along with defining the class we also need to refactor the main script to use the new class. In our previous script everything we wanted to do related to the grid had to be done within the two nested for
loops that created the x and y coordinates used to generate the grid. Now that we have an abstract Cell
class to represent the grid locations, the only thing we need to do in our nested loop is to create a set of instances of the Cell
class and store them in a new list. Then, anything we want to do with the Cell instances can be done by iterating over the list of cells and calling the appropriate method on each one. In this case we call the get_pt()
method to get the center point at each cell, with the rest of the logic remaining the same.
As with the previous example of functions, we have now done quite a bit of refactoring without really adding any new functionality to our code. In fact, with a simple case like this, using a class may indeed be overkill for what can be easily accomplished with a few loops and conditionals. However, representing our problem through a class allows us to abstract the functionality of our program, making it easier to rework or extend that functionality in the future. Even for our simple example, defining a class allows us to separate the efforts of creating a grid of cells from the stuff that each grid cell needs to do. This idea is knowns as separation of concerns and is a key principle behind writing clean, efficient, and maintainable code.
Now that we have our Cell
class defined, let's extend it by wrapping more of our existing functionality inside of it. Let's add a method called get_circle()
which will do everything needed to generate a circle at each grid location. Here is the final code you can paste into the Python
component's code editor:
import Rhino.Geometry as rh
class Cell:
def __init__(self, x, y):
self.x = x
self.y = y
def get_pt(self):
return rh.Point3d(self.x, self.y, 0)
def get_circle(self, attractor, factor):
cp = self.get_pt()
dist = cp.DistanceTo(attractor)
circle = rh.Circle(point, calc_radius(dist, factor))
return circle
def calc_radius(dist, factor=1):
if dist < 2:
radius = 0.25
else:
radius = 0.5
return radius * factor
cells = []
for x in range(int(num_points)):
for y in range(int(num_points)):
cell = Cell(x, y, attractor)
cells.append(cell)
circles = []
points = []
for cell in cells:
point = cell.get_pt()
points.append(point)
for factor in range(1, 10):
circle = cell.get_circle(attractor, factor * 0.2)
circles.append(circle)
Here you can see the new get_circle()
method which requires two inputs - the attractor point and the scaling factor to use for the circle. Within the method we get the grid point using our existing get_pt()
method. We then calculate the distance from that point to the attractor point and construct a new circle object using our previous code. Finally we pass the circle from the method using the return
keyword. Within our main script we simplify the code in the second loop to call the get_circle()
method for each cell instance with each factor value and store the resulting circle geometry in the circles
list.
+Note
It is interesting to note in this example, that since we moved the
.DistanceTo()
call from the main script where it was called once for each grid location to the class method where it is called every time a circle is created, we have made our script less efficient. While this is technically true, efficiency—though always important—is often a relative concept in practice. For example in this case, for the number of circles we are generating, a few extra calls to the.DistanceTo()
method will not have any noticeable affect on how fast my script runs. So as in this case, we often make the tradeoff of accepting somewhat less efficient code if it is more logically structured. However, as with many things in programming, there is no clear right and wrong answer, and the solution you choose becomes more a matter of style than anything relating to strict syntax.
This concludes our extension of the attractor point tutorial using custom functions. In case you got lost along the way, you can download a finished version of the demo here:
+CHALLENGE 9: Changing shape
Now that we have the Cell class defined with a specific method
get_circle()
for generating the geometry, it provides a great framework for extending the functionality of our attractor point grid. For this challenge, can you implement a new method calledget_rect
that creates a rectangle instead of a circle?HINT: You can use the
rh.Rectangle3d
class in place ofrh.Circle()
to create a Rectangle based on a base plane and the dimension in x and y directions. Creating the plane may require using some additional lower level geometry classes such asrh.Plane()
. You can also access global planes and axes vectors usingrh.Plane.WorldXY
andrh.Vector3d.ZAxis
.You may also encounter a similar issue to one of the earlier challenges where the base plane is at the geometry's corner rather than it center. In this case you may need to transform the rectangle geometry using the
.Transform()
method implemented for each of Rhino's geometry classes and passing the appropriate translation object from Rhino'sTransform
library. For example, to move an object you can use:
rect.Transform(rh.Transform.Translation(x, y, z))
BONUS: Can you use the
rect.Rotate()
method and pass the proper inputs to rotate each rectangle based on the same factor used for scaling?