Any value in python is internally handled as an object. This means that the value stored in a variable is a reference to an object. For ex. a = 3 means that the value stored in the variable a is a reference to an object which contains the value 3.
There are also special data types, known as primitive data types which are specially defined and include at least integer, float, and boolean values. Primitives are processed directly, as in they are stored directly as variables, not as references. Python has no such primitives, but working with those data types is practically similar. Objects of these basic data types are immutable, which means that they cannot be changed in memory. Recall that a python reference is conceptually similar to a C pointer. A reference is a memory address or location. A variable holds a reference. That address in the reference points to an object in memory.
If the value stored in a variable of a basic data type needs to be changed, the entire reference is replaced, meaning that the same variable now holds the reference (the memory address) of some other object, and the address of the original object is unaffected, (ex. if a = 4 we store a reference to an object with the value 4), and thus the object stays the same (the object being 3 here, reassigning just “points” or reassigns the reference to a new address, a new object with value 4).
Methods
The data stored in an object is accessed through methods, ex. .append() is method for an object belonging to the builtin class List. Lists, tuples, dictionaries and strings are all special cases in python as they have a predefined unique syntax for declaring an object belonging to each of those data types. Regular objects are declared using constructors.
More precisely, a method is a function that is bound to a specific class. It’s defined with the class definition and can access the data attributes of the class like any other variable. All methods have the parameter self as the first one in their declaration.
Classes
A class is the blueprint for an object and defines the structure and the functionality of the object which would belong to the class. Following this, objects are independent instances of a class, independent in the sense that modification to one generally does nothing to other objects. Real world “entities” are modeled using classes.
Classes are declared using the keyword class and are written in PascalCase or UpperCamelCase (words written together, without spaces, each capitalised).
Any variables attached to an object are called data attributes or instance variables. In python, attributes may also refer to the methods of the objects along with the data attributes (i.e. variables).
It’s possible to create several objects out of a single class, with the exception of a singleton class (VERIFY).
Example:
class Rectangle: def __init__(self, left_upper: tuple(float, float), right_lower: tuple(float, float)): # imagine the screen's top left corner to be 0, 0 # x increases from L to R # y increases from top to bottom if self.tuple_ok(left_upper) and self.tuple_ok(right_lower): self.left_upper = left_upper # keep in mind that these are different variables! self.right_lower = right_lower def __str__(self): return f"Rectangle, {self.left_upper}...{self.right_lower}" def tuple_ok(self, incoming_tuple: tuple): """ Validator method, only allows for positive values for the coordinates """ return incoming_tuple[0] >=0 and incoming_tuple[1] >=0 def transform(self, delta_x: float, delta_y: float): """ Method for the objects belonging to this class. Used to transform a rectangle object by delta_x and delta_y""" corner = self.left_upper corner_x = corner[0] corner_y = corner[1] self.left_upper = (corner_x + delta_x, corner_y + delta_y) corner = self.right_lower corner_x = corner[0] corner_y = corner[1] self.right_lower= (corner_x + delta_x, corner_y + delta_y)
Constructors
these are special functions that are used to initialise an instance of a class, an object. In python we use the dunder method __init__ to declare a constructor. The first parameter in the declaration for the constructor, and all other methods is always self. self is a reference to the current object being instantiated. When we say that self.name = param_name, we assign the data attribute name to the object, which is given the value param_name during instantiation. These are two different variables. For usage of self => self should only be used when you want to access the data attributes of an object inside methods. using self in helper variables is nonsensical as it restricts some data attributes only to that particular method.
Keep in mind that this is a legal approach:
class Person: passnew_person = Person()new_person.name = "Henry" # assign the data attr a value manually
but it’s just that with constructors the approach is not as cumbersome.
Encapsulation and the Client
A client is some program code that’s using the class to create an object and uses the service provided by its methods. When the data contained in an object is used only through the method it provides, the internal integrity (safety related) of the object is guaranteed. A class offers the client services through which the client can access the objects created based on the class. The goal here is to make sure that the integrity of the object is preserved and that it’s use of the class and the objects is simple from client pov. The former, in practice, means that state of the object is always acceptable or that the value of the attributes are legal (for ex. not 13 for a month attribute).
Maintaining the internal integrity of the object and offering suitable methods to ensure the same is called encapsulation. The inner workings of the object are hidden from the client (blackbox), but the object offers methods which can be used to access data stored in the object. Encapsulation can also be understood as hiding data attributes from clients. See the section on private attributes.
Adding a method doesn’t automatically hide the data attributes, but we use access modifiers (covered later).
__init__: special method used for the constructor, or instantiating an object. Example above.
__str__: this method is used to print user-friendly representations of an object when we use the print() command. Example above.
__repr__: used to return a technical representation of an object in data structures such as lists and dictionaries, when we use the print() command. Example below. __repr__ is often implemented so that it returns program code which can be run to return an object with identical contents to the current object. In data structures, Python always uses the __repr__ method to get a string representation of an object.
__iter__ and __next__: both are used for making your classes iterable. See the Misc. heading.
References and objects
Lists, and other non primitive data structures contain references to objects, and no objects themselves. Thus when you have a copy of a list and you modify that, the original gets modified too. This is known as a “shallow” copy, as both share the same reference. In contrast, for creating a “deep” copy you would have to import the copy module and use copy.deepcopy() method to create one. Note that list1 = list2 is just aliasing, or both variables pointing to the exact same memory address (shared reference). On the other hand a shallow copy list1.copy() is a separate new list object in memory. If you have a mutable object inside of list1 and you modify that inside this copy, it would alter the original too. This is where deep copy comes in which recursively creates new copies of every object inside of it.
We use the operator is to check whether two references refer to the same memory address (recall that a reference is the memory address itself). On the other hand, the == operator is used to confirm if the content of the objects is the same.
To read: https://realpython.com/python-mutable-vs-immutable-types/
list1 = [1, 2, 3]list2 = [1, 2, 3]list3 = list1print(list1 is list2) # false, separate assignments so separate references, new objects were created for each.print(list1 is list3) # true, list3 was assigned the reference to list1print(list2 is list3) # false, same reason as the firstprint()print(list1 == list2) # trueprint(list1 == list3) # trueprint(list2 == list3) # true
You can return objects as well as have them as args in methods. Even objects of the same class can be used as arguments in that same class’ methods.
for ex.
class Person: def __init__(self, name: str, age: int): self.name = name self.age = age def older_than(self, another: "Person"): # as we have the class name itself as a type we must put it in quotation marks # we are comparing two Person objects here return self.age < another.age:# we could do something like person1.older_than(person2)# self would be person1 (the object that the method was called on) and another would be person2# of course, `another` can be any variable name
interesting observation => the tmc tests didn’t run when I had not put the class name itself in quotes, but they ran successfully when I did. Interestingly though the program was executing normally (successfully) even when I did NOT put the classname in quotations. Why is it that only the tests were not able to catch it? It’s because the class Person was not fully created until the end of its block and this is the reason we put type hints inside strings because the name cannot be found in the namespace when we are defining the name (here the class Person) itself. This is the reason the tests failed. Also, the program ran because newer versions of python avoid this entirely. To lazily evaluate annotations at runtime you can do from __future__ import annotations (annotations are metadata such as : and -> in function signatures, lazy evaluation means annotations automatically stored as strings so no quotes needed).
Keep in mind that we can do student.course.credits if student is a class that contains an attribute belonging to course class which itself contains the attribute credits. This can be extended too.
None and default values
Note on imports: Imports are only necessary when we are referring to code defined somewhere else.
Let’s now discuss what the keyword None means, and a brief on default values. None is a reference to nothing. Some good use cases are returning None when no object is found, or using it in default values. Python reuses the reference stored in the default value, so mutable data structures such as lists and dicts should not be used as default values. Instead you could use None and provide a conditional check to replace it with the actual list or dict. What reusing means:
class Student: def __init__(self, name, completed_courses=[]): self.name = name self.completed_courses = completed_courses def add_course(self, course): self.completed_courses.append(course)
Here the default value is [] for completed_courses. Now go ahead and create two objects and add different courses to them and you will find out that python is using the same list to store those courses. This means that using the default value is being the equivalent of:
Thus, mutable data structures should not be used as default values.
Private attributes, getters and setters
Classes can hide their attributes from clients. In Python this is done by having a double underscore in the beginning of the the attribute name. This is called a private attribute. This can be done for both data attributes and method attributes, but the latter would be discussed later. Encapsulation is just hiding attributes from clients, or anything that the client shouldn’t see. Note: python doesn’t actually have truly private attributes as in Java and C++, and there are certainly ways to bypass this notation of the double underscore (ex. try _classname__attrname) . This is by design. Look into name mangling.
class BankAccount: """ This class models a standard BankAccount """ def __init__(self, name: str, govt_id: str): self.__name = name # private attribute self.__govt_id = govt_id # private attribute self.__balance = 0 # private attribute def __repr__(self): return f"{repr(self.__name)}'s BankAccount (GOVT_ID: {self.__govt_id}) with balance {self.__balance}" # works with both lists and normal representation def deposit(self, amount: float): if amount > 0: self.__balance += amount def withdraw(self, amount: float): reduced_balance = self.__balance - amount if amount > 0 and reduced_balance >= 0: self.__balance -= amount else: raise ValueError("Insufficient balance!")
Private methods are not accessible by clients either and their main use case is internal helper functions within the class definition that the client doesn’t need to know about. Example:
def __verify_id(self): for character in self.__govt_id: if character in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": return False return True
Getters and Setters
to read: https://realpython.com/primer-on-python-decorators/
This is the pythonic way to interact with private attributes. Getters are used to get a private attribute’s value for the user as a property of the class, while settters can be used to set that attribute to a different value. Getter uses the @property decorator and is declared before the setter, while the setter uses the @prop_name.setter decorator.
Here is a modified class rewritten using getters and setters:
class BankAccount: """ This class models a standard BankAccount """ def __init__(self, name: str, govt_id: str): self.__name = name if self.__verify_id(govt_id): self.__govt_id = govt_id self.__balance = 0 def __repr__(self): return f"{repr(self.__name)}'s BankAccount (GOVT_ID: {self.__govt_id}) with balance {self.__balance}" # works with lists too, unlike __str__ @property def name(self): return self.__name @name.setter def name(self, changed_name: str): self.__name = changed_name @property def name_and_id(self): return f"{self.name}: {self.__govt_id}" @property def balance(self): return self.__balance @balance.setter def balance(self, amount: float): # ideally there should be a withdraw/deposit method if amount > 0: self.__balance = amount def __verify_id(self, govt_id: str): for character in govt_id: if character in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": return False return Trueif __name__ == "__main__": acc = BankAccount("Ishuman", "162006") print(acc) print(acc.name) acc.balance = 200 print(acc.balance) print(acc.name_and_id)
Scope
Namespaces are the names available within a python unit, such as a class or a function definition. Scope is what’s visible from a specific point in program code. More research is to be done here, READ the realpython article.
Class variables, class methods
Class variables or static variables - these belong to class and not the instance. Common use case is some data that is shared across all objects, such as the count of the objects itself. A class variable is declared without the self prefix, and usually outside any method definition as it should be accessible from anywhere within the class, or even from outside the class. Example:
class BankAccount: """ This class models a standard BankAccount """ interest_rate = 5 # 5 percent interest def __init__(self, name: str, govt_id: str): pass # rest of the definition
A class method is different from a static method (look into it). It’s defined with the @classmethod annotation. The first parameter is always cls. The variable name cls is similar to the self parameter. The difference is that cls points to the class while self point to an instance of the class. Neither parameter is included in the argument list when the function is called; Python fills in the appropriate value automatically. Class methods are useful in situations when you want a method belonging to the class itself and not any object, as these can be called before instantiation of a single object.
example:
@classmethod def apply_interest(cls, amount: float): """ Applies the interest rate for one year timeframe """ interest = (amount * BankAccount.interest_rate * 1) / 100 # interest_rate is a class variable print(f"Final amount: {amount+interest}") # call it later using BankAccount.apply_interest()
Inheritance
Traits are both data attributes and method attributes of a class; there’s another definition where it’s a different construct but it’s being used as ‘attributes’ here.
Inheritance is the act of inheriting the traits of another class (ex. Student is Person, Teacher is Person; here Student and Teacher are subclasses of the superclass Person).
Calling an attribute in a base class inside the derived class, or calling a superclass’ attribute by a subclass, is done using super(). All attributes are accessible in the derived class unless they’re private.
Overriding: if a derived class has a method with the same name as the base class then the derived class’ method overrides the original in instances of the derived class. Even if a derived class overrides a method of the base class, it can still call the base class’ (non-overridden) method for that method. Example below.
class ComputerGame: def __init__(self, name: str, publisher: str, year: int): self.name = name self.publisher = publisher self.year = yearclass GameWarehouse: def __init__(self): self.__games = [] def add_game(self, game: ComputerGame): self.__games.append(game) def list_games(self): return self.__gamesclass GameMuseum(GameWarehouse): def __init__(self): super().__init__() def list_games(self): """ Lists games that were made before the year 1990. Overrides the base class' list_games method. """ valid_games = [] # just for the fun of it, let me call the non-overridden method from the base class. # keep in mind that self.__games is still accessible because we had called the base class' constructor. games = super().list_games() for game in games: if game.year <= 1990: valid_games.append(game) return valid_gamesif __name__ == "__main__": museum = GameMuseum() museum.add_game(ComputerGame("Pacman", "Namco", 1980)) museum.add_game(ComputerGame("GTA 2", "Rockstar", 1999)) museum.add_game(ComputerGame("Bubble Bobble", "Taito", 1986)) for game in museum.list_games(): print(game.name)
A note on “protected” attributes
We use single underscores as a convention to “protect” attributes. Protected means that the attribute can be accessed by a derived class but not by a client. The single underscore is just a convention, and according to PEP 008 it means “weak internal use indicator” or it means that from M import * does not import members whose name starts with an underscore. This is also the reason it’s pretty common to see single underscores in helper functions.
Interesting observation: I was getting an LSP warning when I tried to override a method in a derived class: Method “add_ingredient” overrides class “MagicPotion” in an incompatible manner. Positional parameter count mismatch; base method has 3, but override has 4. But this error suddenly went away when I made the class protected. It thus seems that you can’t have a change in number of parameters in overridden methods. Also, the class being made protected changed its method name, that’s the reason the error went away.
Operator overloading: using an operator on our custom objects in our own classes. If you want to be able to use a certain operator on instances of self-defined classes, you can write a special method which returns the correct result of the operator. Refer the table below for the exact methods to implement.
operator
name of method
>
__gt__(self, another)
<
__lt__(self, another)
==
__eq__(self, another)
!=
__ne__(self, another)
<=
__le__(self, another)
>=
__ge__(self, another)
+
__add__(self, another)
-
__sub__(self, another)
*
__mul__(self, another)
/ (floating point result)
__truediv__(self, another)
// (integer result)
__floordiv__(self, another)
Operator overloading also allows you to use methods like .sort().
In operator overloading it’s not important that the other parameter is of the same type as the object, so they can be of any type. For ex. the __sub__ method in the Complex number implementation below takes a float value and subtracts it from the complex number, rather than adding two complex numbers together.
A simple example of operator overloading:
class Complex: """ This class models a complex number """ def __init__(self, real: float, imaginary: float): self.real = real self.imaginary = imaginary def __str__(self): if self.imaginary < 0: return f"{self.real} - {abs(self.imaginary)}i" else: return f"{self.real} + {self.imaginary}i" def __add__(self, another: Complex): """ Adds two complex numbers together """ total_real = self.real + another.real total_imaginary = self.imaginary + another.imaginary return Complex(total_real, total_imaginary) def __sub__(self, value: float): """ Decrements the complex number by a certain value """ decreased_real = self.real - value decreased_imaginary = self.imaginary - value return Complex(decreased_real, decreased_imaginary)if __name__ == "__main__": a = Complex(1, 2) b = Complex(3, -4) print(a) print(b) print(a + b) print(a - 3)
Misc.
[!On the dir function]
It returns the list of names in the current local scope if it’s called without any arguments. With an argument it attempts to return a list of valid attributes for the object. So, as an example:
For making your class iterable you can implement the __iter__ and __next__ methods where the former keeps track of the variable iterating through the collection while the latter keeps track of the next object being traversed. (Example attached). The former returns self while the latter returns then item itself.
class ShoppingList: def __init__(self): self.products = [] def number_of_items(self): return len(self.products) def add(self, product: str, number: int): self.products.append((product, number)) def product(self, n: int): return self.products[n - 1][0] def number(self, n: int): return self.products[n - 1][1] def __iter__(self): # initialises the iteration variable(s) # method returns the reference to the object itself as the iterator is implemented within # the same class definition self.counter = 0 return self def __next__(self): # gives next item within the object # when all items have been traversed, raises a StopIteration exception event if self.counter < self.number_of_items(): item = self.products[self.counter] self.counter += 1 return item else: raise StopIterationif __name__ == "__main__": shopping_list = ShoppingList() shopping_list.add("bananas", 10) shopping_list.add("apples", 5) shopping_list.add("pineapple", 1) for product in shopping_list: print(f"{product[0]}: {product[1]} units")
Selecting items from a list via unpacking
It’s possible to select some items in a list and not select the others. This is done by using a * (a starred expression according to PEP3132) which means that the last variable contains all the remaining items in the list.
Objects can be added as the values in a dictionary, or even the keys. I am just jotting this down because this is something new for me. ex. students[name] = ExerciseCounter() and later
students_counter = students[name] # assigns a helper variable the reference to the object at students[name]students_counter.done() # calls the done() method on the helper variable.
Developing larger applications
This section is a bit different from the others in the fact that it contains no new syntax introductions or new concepts related to object oriented programming. This is just a list of some common software engineering methods that are used in larger applications, especially while using object oriented code. Separation of concerns: Separation of concerns is a design principle for separating a computer program into distinct sections such that each section addresses a separate concern. A concern is a set of information that affects the code of a computer program.
In our OOP context, this would be taking a complex problem such as “I want to make a command line based interface which asks the user for some phone numbers and saves that to a file” into separate concerns: File management, user interface, internal logic, etc. Further, using classes we can separate concerns into even tinier bits, ex. a class for a Phone book, a class for a person, a class for the application, a class for file handling, etc.
A direct quote from the module:
The separation of concerns principle extends to the level of methods, too. We could have the entire functionality of the user interface in a single complicated while loop, but it is better to separate each functionality into its own method. The responsibility of the execute() method is just delegating the commands typed in by the user to the relevant methods. This helps with managing the growing complexity of our program. For example, if we want to later change the way adding entries works, it is immediately clear that we must then focus our efforts on the add_entry() method.
class PhoneBookApplication: def __init__(self): self.__phonebook = PhoneBook() self.__filehandler = FileHandler("phonebook.txt") # the rest of the programapplication = PhoneBookApplication()application.execute()
The name of the file passed into the file handler right now is hard coded, so it’s a dependency. To provide it from outside the object we simply add an extra argument to the initialiser.
class PhoneBookApplication: def __init__(self, storage_service): self.__phonebook = PhoneBook() self.__storage_service = storage_service # the rest of the user interface# create a FileHandlerstorage_service = FileHandler("phonebook.txt")# pass it as an argument to PhoneBookApplication's constructorapplication = PhoneBookApplication(storage_service)application.execute()
This removes an unnecessary dependency and now we have been given the option to look for more exotic storage locations such as cloud services. Ex.
class CloudHandler: # code for saving the contents of the phone book # in a cloud service on the internetstorage_service = CloudHandler("amazon-cloud", "username", "passwrd")application = PhoneBookApplication(storage_service)application.execute()