Lecture 1 - DePaul University



CSC 401 NotesProgrammer-Defined Classes, Part 2OperatorsAs we continue learning how to create our own classes, we need to talk about operators again. There are some subtleties that we haven’t discussed before that become important when we are the ones creating a class.To begin with, recall that methods with names such as __x__ are special “hooks” that are invoked automatically (i.e. invisibly / behind the scenes) in various situations. For example:When we instantiate an object using the initializer, e.g. x = int() or x = Student() etc, Python automatically invokes a method of that class called __init__() When Python evaluates operators other special methods are automatically invoked behind the scenes. For example, when Python encounters the ‘+’ operator, it automatically invokes a method called __add__().Aside: Methods that begin with double-underscores are sometimes called "magic methods" or "dunder methods" (dunder meaning 'double underscore').Example: Consider the following situation:x = 5y = x + 5In this situation, Python will look at the object on the left side of the + operator, and determine the class (data type) it belongs to. Because x is an object of class int, Python will search the int class for a method called __add__(). Python will automatically invoke that method, and pass it TWO arguments. The first argument is the object to the left of the + operator (i.e. x). The second argument will be the object to the right of the + operator, (i.e. 5). Again: If a class implements an __add__() method, this method will automatically be invoked whenever an object of that class appears on the left side of a + expression.I realize this sounds confusing, so let's look at some examples. Then be sure to come back and review the previous paragraph until you clearly understand what is going on. Examples:>>> x=10>>> y=5>>> x+y15>>> x.__add__(y)15>>> x-y5>>> x.__sub__(y)5>>> x*y50>>> x.__mul__(y)50ASIDEQuestion: What happens if the + operator is invoked between two objects, yet that class does NOT have an implementation of the __add__() method? For example, if we had two Student objects and tried to say: s1 + s2, what would happen if we had never written an __add__() method? Answer: You might be tempted to say we will get an error. This is a very good guess. However, before giving an error, the Python interpreter will first check to see if the __add__() method has been implemented in the “parent” class. For all of the classes we have created so far, the parent class is a pre-defined (i.e. pre-existing) class called object. We will discuss more about parent classes when we get to the topic of Inheritance.As you can see, there are magic methods availale for various commonly-used operations. Invoking help() on the class will show us more of these. (Note: I am only showing a select few of the methods here).help(int)| __add__(self, value, /) | Return self+value.| __mul__(self, value, /) | Return self*value.| __str__(self, /) | Return str(self).| __sub__(self, value, /) | Return self-value.| __truediv__(self, value, /) | Return self/value.Some of the functions have fairly intuitive identifiers. For example, in addition to __add__(), __mul__(), etc, note the function to cast to a string, (i.e. if we invoke str(5) ). The str() function is mapped by Python to a method __str__().>>> x = 3>>> str(x)'3'>>> x.__str__() #i.e. same thing as above'3'Other dunder methods, have less obvious identifers. For example, note that the function to do division is called __truediv__(). Again, the help() function is your friend as you can explore the various classes and learn about some of the functionality available to you. And, of course, if we invoke help() for the any other class such as list, we will see all kinds of magic methods there as well. Example: Recall that we can concatenate two lists together with the + operator. This works because inside the list class, we will find that the creators of that class implemented an __add__() method which takes the two lists on either side of the + operator, combines them into a single list, and returns that list. >>> lst = list([2, 3, 4, 5, 6])>>> lst[2, 3, 4, 5, 6]>>> len(lst)5>>> lst.__len__()5>>> lst.__add__([4,5])[2, 3, 4, 5, 6, 4, 5]>>> lst[2, 3, 4, 5, 6]In other words, whenever you see the + character, remember that this character is simply a “nickname” for the method, which is technically called __add__(). And this is true for all kinds of other operators such as * - == / < > <= >= and many others. Here is a partial list of operators and the names of the methods that Python maps to:+object.__add__(self, other)-object.__sub__(self, other)*object.__mul__(self, other)//object.__floordiv__(self, other)/object.__truediv__(self, other)%object.__mod__(self, other)**object.__pow__(self, other[, modulo])<<object.__lshift__(self, other)>>object.__rshift__(self, other)&object.__and__(self, other)^object.__xor__(self, other)|object.__or__(self, other)<object.__lt__(self, other)<=object.__le__(self, other)==object.__eq__(self, other)!=object.__ne__(self, other)>=object.__ge__(self, other)>object.__gt__(self, other)We DO need to understand (and you can expect to be asked!) how to invoke methods using this technique. For example, make sure you can carry out the two short exercises below. Exercises:Define a couple of integer objects and use the __name__ notation for the operators add, sub, and mul on those integers.For example, mapping the + operator: x=3 y=5 print( x.__add__(y) )Define a couple of list objects and use the __name__ notation for the operators contains, len, and getitem.Class Variables vs Instance VariablesWhen we use the term ‘class’ variables we are contrasting with the term ‘instance’ variables. Let’s look at the top of our Student class file. Note that there is a variable called course. Because this variable is NOT defined inside a method, it is a class variable. An instance variable means that every single object of that class has its own copy of that variable. A class variable means that there is only ONE copy of that variable, and all objects share that variable. Recall that in order to make a variable an instance variable, it must be defined (i.e. created) inside a method of the class. In order to make a variable a class variable, it must be defined outside of any class method. They are typically placed directly below the class declaration (though after the docstring).Here is the top of our Student class. Note that we have a variable called course that is defined inside the class, but was not defined inside any method. This makes it a class variable. class Student(object): 'class docstring' course = 'CSC-243' #class variable def __init__(self, sID=-1, name='', midtermScore=-1, feScore=-1): self.sID = sID #instance variable self.name = name#instance variable ETC…Also note that there are several other variables that are defined (created) inside methods, making them instance variables. We can see some that are defined in the initializer, but if we look at the code for the entire class, we’ll see other instance variables that have been defined in various other methods.sID, name, midtermGrade, finalGrade are are defined (created) inside the initializerpercentGrade is defined inside the method calcPercentScore()letterGrade is defined inside the method determineLetterGrade()Let’s look at the behavior of instance vs class variables:>>> s1 = Student()>>> s2 = Student()>>> s1.name #instance variable''>>> s2.name #instance variable''>>> s1.name = 'Bob'>>> s1.name'Bob'>>> s2.name ''Note that s2 was not affected when we changed s1’s name. This is because s1 and s2 each have their own copy of the variable name. A class variable means that there is only ONE copy of that variable for all instances of the class. For example, even if we had 20 objects of type Student, there would be only one copy of the variable course. When you think about it, it makes sense to have made course a shared variable. Since this class is intended to only work for a particular course, there is no need to waste memory and possibly create inconsistency by making it an instance variable. These kinds of decisions take place during the planning phases of a programing project. Software engineering and algorithm courses spend a fair amount of time discussing these types of things. Because course is a class variable, we can access it not only by providing the name of any Student object, e.g. >>> Student.course'CSC-243'we can also access it by providing the name of any Student object: >>> s2.course'CSC-243'However, we typically try to always use the class name unless some software engineering principal dictates otherwise. Important: Use the class name to access and modify class variablesAs indicated, we can access class variables via either the class name, or via any instance name. However, unless you have a specific reason for doing so, you should avoid using an instance when accessing class variables. Typically, always try to use the class name. Briefly, if you use the instance name, you may find that you have created a new (i.e. extra) version of this variable inside the namespace of that particular instance. For example, in our Student class, if you wish to modify course, do NOT use an instance name. Instead, use the class name:>>> s1.course = "Blah" #Avoid! >>> Student.course = "Blah" #Much BetterAlso note that once we have changed the value of a class variable, every instance of the class will reflect that change. Again, this is because all instances are sharing a single copy of that variable. >>> Student.course = 'Blah'>>> s1.course #s1's course has changed'Blah'>>> s2.course #s2's course has changed'Blah'One example of where we might use a class variable:In our Student class, I have created an additional class variable called number_of_students. I initialized this value to 0.However, in the initializer, I increase the value of this variable. If you think about it, it means that every single time I instantiate a Student, I increase the value of this variable by one. This way, at any given moment in a program, I can keep track of how many students there are. >>> s1 = Student()>>> s2 = Student()>>> s3 = Student()>>> Student.number_of_students3Namespaces and ClassesAs we know, the general form of a Python class statement is as follows:class <name> (superclass, …) # assign to name data = value# class variables def method (self, …)# class methods self.member = value# instance variablesIt is very helpful to understand that in Python a class essentially defines something we call a namespace.The class attributes (variables and methods) are just names defined in the namespace of the class.Further, every instance of a class gets its own namespace and inherits class attributes from the namespace of its class.For example, every single time we instantiate a new instance of type Account, a new namespace gets created. This namespace will inherit all of the attributes (fields and methods) from the namespace of the Account class:>>> a1 = Account()>>> a1.setInterest(0.05)This may sound confusing, but you probably already understand the underlying concept. When we instantiated a1, we got a new namespace for that object. This a1 object has its own copy of name, balance, rate and so on. If we then instantiate another Account object, say, a2, an entirely new namespace is created that has its own copy of name, balance, rate, etc. Important: Python automatically maps the invocation of a method by an instance of a class to a call to a function defined in the class namespace on the instance argument.Okay, I realize the above sentence is really confusing. However, as we've done before, let's look at some examples. Then come back and review the above paragraph. Rinse, lather, and repeat as needed until the concept becomes clear.You certainly do not have to memorize that awkward sentence, but you must understand how the code works. However, never fear – we will discuss it now! A method invocation like:instance.method(arg1, arg2, …)is mapped (translated) by the Python interpreter into:class_name.method(instance, arg1, arg2, …)For example Python maps (i.e. converts): >>> a1.setInterest(0.05)to the following code:>>> Account.setInterest(a1, 0.05)NOTE: This IS the version that the Python interpreter uses. The interpreter automatically does this translation behind the scenes. It is important to be aware of how this works. This "translation" also explains the self variable that we have seen so much of.The self argument that we have been including in all of our class methods refers to the particular instance making the method call (e.g. a1, a3, a24, acctX, acctY, etc etc). Further, the self.xxxx prefix in front of instance variables refers to the namespace of that particular instance. Example: def setInterest(self, newRate): self.rate = newRateSo if we invoke the method via: a23.setInterest(0.05)The self parameter is replaced throughout the method by a23. Exercise: Add a method to the class Account called deposit(). The method takes a parameter value and increases the (instance) variable balance by value. def deposit(self, amount): 'adds amount to balance' self.balance += amountHopefully after spending a little time reading / reviewing this concept, you will be clear as to why we need the self parameter in our methods. An exerciseTo review the basics of classes, do the following exercise:Develop a new class called Animal that abstracts animals and supports three methods:setSpecies(): Takes a species (a string) as a parameter and sets the species of the animal object to itsetLanguage(): Takes a language (a string) as a parameter and sets the language of the animal object to it.speak(): Take no parameters and returns a message from the animal shown as below.An example of how the Animal class would be used is provided below:>>> snoopy = Animal()>>> snoopy.setSpecies('dog')>>> snoopy.setLanguage('bark')>>> snoopy.speak()'I am a dog and I bark'You can see my version of this class, including some additional features in animal.py.String representations of objectsSuppose you tried to print() of an object from the first version of our Account class (i.e. the one defined in BankAccount_v1.py):>>> a1 = Account('Bob Smith',11111,bal=500,intRate=0.02)>>> a1.name'Bob Smith'>>> print(a1)<__main__.Account object at 0x000001F5B2EE85C0>As you can see, invoking print() on the object a1 gives us a less than ideal string representation of the object!We have already discussed "overloaded" operators when we talked about what takes place when we use operators such as: + which maps to __add__() * which maps to __mul__() Similarly, we have seen that whenever we invoke a initializer, Python automagically maps the initializer call to a method called __init__()Well, a very similar thing happens whenever we invoke print(), Python maps this function to a class method called: __str__(). This means that if we override this method __str__() (we will discuss what that term means soon), we can arrange for a much nicer version of an output string to be returned. Now, it should be noted that there are two commonly used methods that return a string representation of the object: __repr__() and __str__()The method __str__() is supposed to return a “user friendly” string. This string is intended to be a very ‘readable’ representation of the object. The method __repr__() is supposed to return a more ‘formal’ (sometimes referred to as “canonical”) string representation representation of the object. It often includes things like the name of the class, and some other data. This version of string representation can be useful when it comes to development and debugging. While you should understand what it is, we will not use it much if at all during this course.Example: To see the difference between these two string representations, let’s istantiate an object of class datetime. You don’t need to worry about the details of this class. However, because this class has implemented both of the string representation methods __str__() and __repr__() we can print out both versions and see what the difference looks like. >>> import datetime>>> currentTime = datetime.datetime.now()>>> print(currentTime)2019-02-28 11:28:25.390841 #__str__() was invokedIn the above example, when we invoked print() on the object, we get the __str__() version. Note that this is a pretty ‘readable’ version of the object. With very little effort, we can figure out the date and (exact!) time. To see the __repr__() version, wen can use a built-in method called rep():>>> repr(currentTime)'datetime.datetime(2019, 2, 28, 11, 28, 25, 390841)'As you can see, this version is not quite as "friendly". This version is used more often for debugging.Note: If you type print() and pass the object as the argument, you will always get the __str__() version or the object. However, whenever you type an object at the Python command line (e.g. in the IDLE shell) without typing print(), you get the __repr__() version. >>> print(currentTime) #uses __str__()2019-02-28 11:28:25.390841>>> currentTime #uses __repr__()datetime.datetime(2019, 2, 28, 11, 28, 25, 390841)A properly written class will typically have both of these methods defined in the class. However, for now, we will focus on writing the __str__()version. Exercise: Add the __str__ method to the Account definition. Make the display of the string method show a dollar sign, and display the interest rate in non-decimal form (i.e. Intead of 0.025, it should say 2.5%). See below for examples.>>> a1 = Account('Elton John',4444,30000000,0.02)>>> print(a1)Name:Elton JohnID:4444Balance: $30000000Interest Rate: 2.0%Here is the code: def __str__(self): 'returns a nicely formatted string representation of the object' s = 'Name:\t{}\n\ID:\t{}\n\Balance: ${}\n\Interest Rate: {}%'.format(self.name,self.custId,self.balance,self.rate*100) return sAside: Recall that because we are inserting line breaks inside of a string, we need to avoid any and all extraneous whitespace – including Python indentations. The reason this doesn't cause problems is that because we are inside of a string, Python is not trying to interpret those remaining lines of code as separate programming statements.When we invoke the print function as: print(a1), Python maps this to the Account class' __str__() method. The actual function call behind the scenes would look like this:print( Account.__str__(a1) ) Because the code of the __str__() method returns a string, that string ends up being the argument to the print() method.Overriding the equality operator As we have seen, by overriding common operators and methods such as ==, +, *, split(), len(), etc. we can define class behavior in familiar ways. For example, we may wish to compare two objects to see if they are identical. Let’s look at another commonly used operator. Let’s try to use the familiar ‘==’ operator on two Account objects and see what happens. >>> a1 = Account()>>> a2 = Account()Now let’s see if these two objects are identical:>>> a1==a2FalseThe problem is that by default, comparing two objects with the ‘==’ operator only tells us if they are aliases of each other. That is, it only returns True if the two names are referencing the same single object. We can show that this is not the case by looking at the id (memory address) of the object the two objects are referencing:>>> id(a1)47717296>>> id(a2)94419984Note: The value returned by the id() method will change every time you run your program. However, we can be sure that the value returned will be different between the two Account objects.As we can see, a1 and a2 are indeed referncing two entirely different objects.However, it seems that it might be far more useful to overload the ‘==’ operator so that it returns True if the values of all instance variables of two objects are identical. To overload the equality operator, we create a dunder method called: __eq__() This method will look at all of the instance variables of the calling object and a second object (that is passed as an argument). If the instance variables are all identical, the method will return True. Otherwise, the method will return False. Example: Let’s overload the ‘==’ operator in the Animal class. Recall that this class has two instance variables, species, and language: def __eq__(self, other): 'return True if two Animals have the same species and language' if self.species == other.species and self.language == other.language: return True else: return FalseLet’s test:>>> garfield = Animal('cat','meow')>>> odie = Animal('dog','bark')>>> sylvester = Animal('cat','meow')Comparing garfield with sylvester will return True since the species and language for both are identical:>>> garfield == sylvesterTrueHowever, this is not true for garfield and odie.>>> garfield == odieFalseOther OperatorsWe can (and often should) override certain other operators as well. For example:To overload the ‘+’ operator, we would create a method called __add__()To overload the ‘>’ operator, we would create a method called __gt__() To overload the ‘<’ operator, we would create a method called __lt__() There are several other methods corresonding to frequently used operators. Hopefully you recognize that these methods may not apply to all classes. For example, it's hard to envision how we would "add" two Student objects together. Or for that matter, how a < or > operator would apply to Student or Account objects. CodeUpdated versions of the Student and Account class can be found in: Student_v2.py and BankAccount_v2.py respectively.Another Example: class PointLet's look at another class we will call Point. This class represents a two-dimensional grid with x and y coordinates like you would have encountered in a basic math class. Image CitationOur class will have two instance variables, one called x which will refer to the x-coordinate, and a second one called y referring to the y-coordinate. Here is the opening part of our class (again doc strings left out for brevity, but are present in the completed class):class Point(object): def __init__(self,xCoord=0,yCoord=0): self.x = xCoord self.y = yCoordleft0Aside: If you look at the class itself (i.e. the Python file), you will see a slight variation to this initializer. Inside the class, I have a method called move() that allows us to assign both the x and y fields at the same time. Because I have this method, the initializer is written as: def __init__(self,xCoord=0,yCoord=0): self.move(xCoord, yCoord)Here is the move() method: def move(self,newX,newY): self.x = newX self.y = newYThe point is: If you have gone to the trouble to create a method, you should always try to use it! The reason is that inside methods we can do things like error checking, formatting, and other things. In this particular instance, we haven't done anything special, but it's still a good Software Engineering principle. 0Aside: If you look at the class itself (i.e. the Python file), you will see a slight variation to this initializer. Inside the class, I have a method called move() that allows us to assign both the x and y fields at the same time. Because I have this method, the initializer is written as: def __init__(self,xCoord=0,yCoord=0): self.move(xCoord, yCoord)Here is the move() method: def move(self,newX,newY): self.x = newX self.y = newYThe point is: If you have gone to the trouble to create a method, you should always try to use it! The reason is that inside methods we can do things like error checking, formatting, and other things. In this particular instance, we haven't done anything special, but it's still a good Software Engineering principle. Let's experiment with our Point class for a moment: >>> p1 = Point(2,3)>>> print(p1)(2,3)>>> p1.move(5, -2)>>> print(p1)(5,-2)>>> p1.adjust(1, 1) #Add 1 to x and 1 to y>>> print(p1)(6,-1)Now imagine you have a second Point object:>>> p2 = Point(3,4)Suppose when we are in the design phase or creating our class, we anticipate the need to "add" two Point objects together, so that a new object is returned with the new instances variables being the sum of the x coordinates, and the sum of the y coordinates. That is, if we have one point with the coordinates (5,-1) and a second object with the coordinates (3,4), then adding the two points would give us a new point with the coordinates: (8, 2)In programming terms, we'd like to do something like this:>>> p1 = Point(5,-1)>>> p2 = Point(3,4)>>> p3 = p1+p2Now let’s say in our design phase, we realize that we would like to be able to take 2 Point objects, and add the 2 x’s, and then add the 2 y’s and generate a new Point object from these values. For example, with the 2 Point objects above, what we want is for the + operator to give us a new point object with the coordinates (8,3) i.e. ( 5+1, -1+4)To do so, we will override the + operator. Recall that in order to accomplish this, we must define the method __add__(). Here is the code that we use to do this: def __add__(self,other): '''Takes 2 Point objects, and returns a new Point with the coordinates added''' newX = self.x + other.x newY = self.y + other.y return Point(newX,newY)>>> p3 = p1+p2>>> print(p3)(8,3)Important: Note that there is a second parameter in this method (i.e. beyond self). This is because when we invoke this method, we are working with TWO objects: p1 and p2 (or whichever two objects we are trying to add together). Let's review how the method gets translated:>>> p3 = p1+p2is translated into:p3 = p1.__add__(p2)Inside the method, the self parameter refers to p1, while the parameter we have named other refers to p2. Again, this is very important to understanding how things work, so, if you are confused by this, be sure to review the "mapping" (or "translation") concept we have discussed previously!Here is another very plausible bit of functionality. Suppose we wish to compare two Point objects to see if both the x and y coordinates are the same. For example, given>>> p1 = Point(5,-1)>>> p2 = Point(3,4)>>> p3 = Point(3,4)We would like to see:>>> p1==p2False>>> p2==p3TrueIn other words, we would like to be able to use the == operator to compare the value of the two objects. Recall that by default, == compares the identity (i.e. the memory location) of the two objects. That is, == (in its default form) simply tells us if two identifiers are aliases of each other. In order to override the equality operator (==), we must define the method __eq__()Here is the code we use to do this: def __eq__(self,other): 'returns true if both x and y are identical' return (self.x == other.x and self.y==other.y)As we discussed with the __add__() method above, note that we again have a second parameter in our method. Let's review how the == operator gets translated:p1==p2is translated into:p1.__eq__(p2)ExercisesExercise 1: Add an equality method to the Animal class. Two Animals are equal if their species and languages are the same.>>> a1 = Animal('dog', 'woof')>>> a2 = Animal('dog', 'yipyipyip')>>> a3 = Animal('cat', 'woof')>>> a4 = Animal('dog', 'woof')>>> a1 == a2False>>> a2 == a3False>>> a3 == a4False>>> a1 == a4TrueExercise 2: Add a concatenation operator to the Animal class that creates hybrids of the animals being concatenated. See below for an example:>>> pelican = Animal('cat', '___meow___')>>> pelicanAnimal(cat, ___meow___)>>> micah = Animal('dog', 'woof')>>> micahAnimal(dog, woof)>>> clyde = pelican + micah>>> clydeAnimal(cat/dog, ___meow___/woof)See the solution in animal.py. ................
................

In order to avoid copyright disputes, this page is only a partial summary.

Google Online Preview   Download