INTRODUCTION TO PYTHON
BY-RAVI SHANKAR
1. Python Objects and Classes
Python is an object-oriented programming language. Unlike procedure-oriented programming, where the main emphasis is on functions, object-oriented programming stresses on objects.
An object is simply a collection of data (variables) and methods (functions) that act on those data. Similarly, a class is a blueprint for that object.
We can think of a class as a sketch (prototype) of a house. It contains all the details about the floors, doors, windows, etc. Based on these descriptions we build the house. House is the object.
As many houses can be made from a house’s blueprint, we can create many objects from a class. An object is also called an instance of a class and the process of creating this object is called instantiation.
Defining a Class in Python
Like function definitions begin with the def keyword in Python, class definitions begin with a class keyword.
The first string inside the class is called docstring and has a brief description of the class. Although not mandatory, this is highly recommended.
Here is a simple class definition.
class MyNewClass:
'''This is a docstring. I have created a new class'''
pass
A class creates a new local namespace where all its attributes are defined. Attributes may be data or functions.
There are also special attributes in it that begins with double underscores __
. For example, __doc__
gives us the docstring of that class.
As soon as we define a class, a new class object is created with the same name. This class object allows us to access the different attributes as well as to instantiate new objects of that class.
class Person:
"This is a person class"
age = 10 def greet(self):
print('Hello')
# Output: 10
print(Person.age)# Output: <function Person.greet>
print(Person.greet)# Output: "This is a person class"
print(Person.__doc__)
Output
10
<function Person.greet at 0x7fc78c6e8160>
This is a person class
Creating an Object in Python
We saw that the class object could be used to access different attributes.
It can also be used to create new object instances (instantiation) of that class. The procedure to create an object is similar to a function call.
>>> harry = Person()
This will create a new object instance named harry. We can access the attributes of objects using the object name prefix.
Attributes may be data or method. Methods of an object are corresponding functions of that class.
This means to say, since Person.greet
is a function object (attribute of class), Person.greet
will be a method object.
class Person:
"This is a person class"
age = 10 def greet(self):
print('Hello')
# create a new object of Person class
harry = Person()# Output: <function Person.greet>
print(Person.greet)# Output: <bound method Person.greet of <__main__.Person object>>
print(harry.greet)# Calling object's greet() method
# Output: Hello
harry.greet()
Output
<function Person.greet at 0x7fd288e4e160>
<bound method Person.greet of <__main__.Person object at 0x7fd288e9fa30>>
Hello
You may have noticed the self
parameter in function definition inside the class but we called the method simply as harry.greet()
without any arguments. It still worked.
This is because, whenever an object calls its method, the object itself is passed as the first argument. So, harry.greet()
translates into Person.greet(harry)
.
In general, calling a method with a list of n arguments is equivalent to calling the corresponding function with an argument list that is created by inserting the method’s object before the first argument.
For these reasons, the first argument of the function in class must be the object itself. This is conventionally called self. It can be named otherwise but we highly recommend to follow the convention.
Now you must be familiar with class object, instance object, function object, method object and their differences.
Constructors in Python
Class functions that begin with double underscore __
are called special functions as they have special meaning.
Of one particular interest is the __init__()
function. This special function gets called whenever a new object of that class is instantiated.
This type of function is also called constructors in Object Oriented Programming (OOP). We normally use it to initialize all the variables.
class ComplexNumber:
def __init__(self, r=0, i=0):
self.real = r
self.imag = i def get_data(self):
print(f'{self.real}+{self.imag}j')
# Create a new ComplexNumber object
num1 = ComplexNumber(2, 3)# Call get_data() method
# Output: 2+3j
num1.get_data()# Create another ComplexNumber object
# and create a new attribute 'attr'
num2 = ComplexNumber(5)
num2.attr = 10# Output: (5, 0, 10)
print((num2.real, num2.imag, num2.attr))# but c1 object doesn't have attribute 'attr'
# AttributeError: 'ComplexNumber' object has no attribute 'attr'
print(num1.attr)
Output
2+3j
(5, 0, 10)
Traceback (most recent call last):
File "<string>", line 27, in <module>
print(num1.attr)
AttributeError: 'ComplexNumber' object has no attribute 'attr'
In the above example, we defined a new class to represent complex numbers. It has two functions, __init__()
to initialize the variables (defaults to zero) and get_data()
to display the number properly.
An interesting thing to note in the above step is that attributes of an object can be created on the fly. We created a new attribute attr for object num2 and read it as well. But this does not create that attribute for object num1.
Deleting Attributes and Objects
Any attribute of an object can be deleted anytime, using the del
statement. Try the following on the Python shell to see the output.
>>> num1 = ComplexNumber(2,3)
>>> del num1.imag
>>> num1.get_data()
Traceback (most recent call last):
...
AttributeError: 'ComplexNumber' object has no attribute 'imag'>>> del ComplexNumber.get_data
>>> num1.get_data()
Traceback (most recent call last):
...
AttributeError: 'ComplexNumber' object has no attribute 'get_data'
We can even delete the object itself, using the del statement.
>>> c1 = ComplexNumber(1,3)
>>> del c1
>>> c1
Traceback (most recent call last):
...
NameError: name 'c1' is not defined
Actually, it is more complicated than that. When we do c1 = ComplexNumber(1,3)
, a new instance object is created in memory and the name c1 binds with it.
On the command del c1
, this binding is removed and the name c1 is deleted from the corresponding namespace. The object however continues to exist in memory and if no other name is bound to it, it is later automatically destroyed.
This automatic destruction of unreferenced objects in Python is also called garbage collection.
2. Python Closures
Before getting into what a closure is, we have to first understand what a nested function and nonlocal variable is.
A function defined inside another function is called a nested function. Nested functions can access variables of the enclosing scope.
In Python, these non-local variables are read-only by default and we must declare them explicitly as non-local (using nonlocal keyword) in order to modify them.
Following is an example of a nested function accessing a non-local variable.
def print_msg(msg):
# This is the outer enclosing function def printer():
# This is the nested function
print(msg) printer()# We execute the function
# Output: Hello
print_msg("Hello")
Output
Hello
We can see that the nested printer()
function was able to access the non-local msg variable of the enclosing function.
Defining a Closure Function
In the example above, what would happen if the last line of the function print_msg()
returned the printer()
function instead of calling it? This means the function was defined as follows:
def print_msg(msg):
# This is the outer enclosing function def printer():
# This is the nested function
print(msg) return printer # returns the nested function
# Now let's try calling this function.
# Output: Hello
another = print_msg("Hello")
another()
Output
Hello
That’s unusual.
The print_msg()
function was called with the string "Hello"
and the returned function was bound to the name another. On calling another()
, the message was still remembered although we had already finished executing the print_msg()
function.
This technique by which some data ("Hello
in this case) gets attached to the code is called closure in Python.
This value in the enclosing scope is remembered even when the variable goes out of scope or the function itself is removed from the current namespace.
Try running the following in the Python shell to see the output.
>>> del print_msg
>>> another()
Hello
>>> print_msg("Hello")
Traceback (most recent call last):
...
NameError: name 'print_msg' is not defined
Here, the returned function still works even when the original function was deleted.
When do we have closures?
As seen from the above example, we have a closure in Python when a nested function references a value in its enclosing scope.
The criteria that must be met to create closure in Python are summarized in the following points.
- We must have a nested function (function inside a function).
- The nested function must refer to a value defined in the enclosing function.
- The enclosing function must return the nested function.
When to use closures?
So what are closures good for?
Closures can avoid the use of global values and provides some form of data hiding. It can also provide an object oriented solution to the problem.
When there are few methods (one method in most cases) to be implemented in a class, closures can provide an alternate and more elegant solution. But when the number of attributes and methods get larger, it’s better to implement a class.
Here is a simple example where a closure might be more preferable than defining a class and making objects. But the preference is all yours.
def make_multiplier_of(n):
def multiplier(x):
return x * n
return multiplier
# Multiplier of 3
times3 = make_multiplier_of(3)# Multiplier of 5
times5 = make_multiplier_of(5)# Output: 27
print(times3(9))# Output: 15
print(times5(3))# Output: 30
print(times5(times3(2)))
Output
27
15
30
Python Decorators make an extensive use of closures as well.
On a concluding note, it is good to point out that the values that get enclosed in the closure function can be found out.
All function objects have a __closure__
attribute that returns a tuple of cell objects if it is a closure function. Referring to the example above, we know times3
and times5
are closure functions.
>>> make_multiplier_of.__closure__
>>> times3.__closure__
(<cell at 0x0000000002D155B8: int object at 0x000000001E39B6E0>,)
The cell object has the attribute cell_contents which stores the closed value.
>>> times3.__closure__[0].cell_contents
3
>>> times5.__closure__[0].cell_contents
5
3. Python Decorators
Python has an interesting feature called decorators to add functionality to an existing code.
This is also called metaprogramming because a part of the program tries to modify another part of the program at compile time.
Prerequisites for learning decorators
In order to understand about decorators, we must first know a few basic things in Python.
We must be comfortable with the fact that everything in Python (Yes! Even classes), are objects. Names that we define are simply identifiers bound to these objects. Functions are no exceptions, they are objects too (with attributes). Various different names can be bound to the same function object.
Here is an example.
def first(msg):
print(msg)
first("Hello")second = first
second("Hello")
Output
Hello
Hello
When you run the code, both functions first
and second
give the same output. Here, the names first
and second
refer to the same function object.
Now things start getting weirder.
Functions can be passed as arguments to another function.
If you have used functions like map
, filter
and reduce
in Python, then you already know about this.
Such functions that take other functions as arguments are also called higher order functions. Here is an example of such a function.
def inc(x):
return x + 1
def dec(x):
return x - 1
def operate(func, x):
result = func(x)
return result
We invoke the function as follows.
>>> operate(inc,3)
4
>>> operate(dec,3)
2
Furthermore, a function can return another function.
def is_called():
def is_returned():
print("Hello")
return is_returned
new = is_called()# Outputs "Hello"
new()
Output
Hello
Here, is_returned()
is a nested function which is defined and returned each time we call is_called()
.
Finally, we must know about Closures in Python.
Getting back to Decorators
Functions and methods are called callable as they can be called.
In fact, any object which implements the special __call__()
method is termed callable. So, in the most basic sense, a decorator is a callable that returns a callable.
Basically, a decorator takes in a function, adds some functionality and returns it.
def make_pretty(func):
def inner():
print("I got decorated")
func()
return inner
def ordinary():
print("I am ordinary")
When you run the following codes in shell,
>>> ordinary()
I am ordinary>>> # let's decorate this ordinary function
>>> pretty = make_pretty(ordinary)
>>> pretty()
I got decorated
I am ordinary
In the example shown above, make_pretty()
is a decorator. In the assignment step:
pretty = make_pretty(ordinary)
The function ordinary()
got decorated and the returned function was given the name pretty
.
We can see that the decorator function added some new functionality to the original function. This is similar to packing a gift. The decorator acts as a wrapper. The nature of the object that got decorated (actual gift inside) does not alter. But now, it looks pretty (since it got decorated).
Generally, we decorate a function and reassign it as,
ordinary = make_pretty(ordinary).
This is a common construct and for this reason, Python has a syntax to simplify this.
We can use the @
symbol along with the name of the decorator function and place it above the definition of the function to be decorated. For example,
@make_pretty
def ordinary():
print("I am ordinary")
is equivalent to
def ordinary():
print("I am ordinary")
ordinary = make_pretty(ordinary)
This is just a syntactic sugar to implement decorators.
Decorating Functions with Parameters
The above decorator was simple and it only worked with functions that did not have any parameters. What if we had functions that took in parameters like:
def divide(a, b):
return a/b
This function has two parameters, a and b. We know it will give an error if we pass in b as 0.
>>> divide(2,5)
0.4
>>> divide(2,0)
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
Now let’s make a decorator to check for this case that will cause the error.
def smart_divide(func):
def inner(a, b):
print("I am going to divide", a, "and", b)
if b == 0:
print("Whoops! cannot divide")
return return func(a, b)
return inner
@smart_divide
def divide(a, b):
print(a/b)
This new implementation will return None
if the error condition arises.
>>> divide(2,5)
I am going to divide 2 and 5
0.4>>> divide(2,0)
I am going to divide 2 and 0
Whoops! cannot divide
In this manner, we can decorate functions that take parameters.
A keen observer will notice that parameters of the nested inner()
function inside the decorator is the same as the parameters of functions it decorates. Taking this into account, now we can make general decorators that work with any number of parameters.
In Python, this magic is done as function(*args, **kwargs)
. In this way, args
will be the tuple of positional arguments and kwargs
will be the dictionary of keyword arguments. An example of such a decorator will be:
def works_for_all(func):
def inner(*args, **kwargs):
print("I can decorate any function")
return func(*args, **kwargs)
return inner
Chaining Decorators in Python
Multiple decorators can be chained in Python.
This is to say, a function can be decorated multiple times with different (or same) decorators. We simply place the decorators above the desired function.
def star(func):
def inner(*args, **kwargs):
print("*" * 30)
func(*args, **kwargs)
print("*" * 30)
return inner
def percent(func):
def inner(*args, **kwargs):
print("%" * 30)
func(*args, **kwargs)
print("%" * 30)
return inner
@star
@percent
def printer(msg):
print(msg)
printer("Hello")
Output
******************************
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
Hello
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
******************************
The above syntax of,
@star
@percent
def printer(msg):
print(msg)
is equivalent to
def printer(msg):
print(msg)
printer = star(percent(printer))
The order in which we chain decorators matter. If we had reversed the order as,
@percent
@star
def printer(msg):
print(msg)
The output would be:
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
******************************
Hello
******************************
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
4. Python Descriptors
Descriptors are Python objects that implement a method of the descriptor protocol, which gives you the ability to create objects that have special behavior when they’re accessed as attributes of other objects. Here you can see the correct definition of the descriptor protocol:
__get__(self, obj, type=None) -> object
__set__(self, obj, value) -> None
__delete__(self, obj) -> None
__set_name__(self, owner, name)
If your descriptor implements just .__get__()
, then it’s said to be a non-data descriptor. If it implements .__set__()
or .__delete__()
, then it’s said to be a data descriptor. Note that this difference is not just about the name, but it’s also a difference in behavior. That’s because data descriptors have precedence during the lookup process, as you’ll see later on.
Take a look at the following example, which defines a descriptor that logs something on the console when it’s accessed:
# descriptors.py
class Verbose_attribute():
def __get__(self, obj, type=None) -> object:
print("accessing the attribute to get the value")
return 42
def __set__(self, obj, value) -> None:
print("accessing the attribute to set the value")
raise AttributeError("Cannot change the value")class Foo():
attribute1 = Verbose_attribute()my_foo_object = Foo()
x = my_foo_object.attribute1
print(x)
In the example above, Verbose_attribute()
implements the descriptor protocol. Once it’s instantiated as an attribute of Foo
, it can be considered a descriptor.
As a descriptor, it has binding behavior when it’s accessed using dot notation. In this case, the descriptor logs a message on the console every time it’s accessed to get or set a value:
- When it’s accessed to
.__get__()
the value, it always returns the value42
. - When it’s accessed to
.__set__()
a specific value, it raises anAttributeError
exception, which is the recommended way to implement read-only descriptors.
Now, run the example above and you’ll see the descriptor log the access to the console before returning the constant value:
$ python descriptors.py
accessing the attribute to get the value
42
Here, when you try to access attribute1
, the descriptor logs this access to the console, as defined in .__get__()
.
How Descriptors Work in Python’s Internals
If you have experience as an object-oriented Python developer, then you may think that the previous example’s approach is a bit of overkill. You could achieve the same result by using properties. While this is true, you may be surprised to know that properties in Python are just… descriptors! You’ll see later on that properties are not the only feature that make use of Python descriptors.
Python Descriptors in Properties
If you want to get the same result as the previous example without explicitly using a Python descriptor, then the most straightforward approach is to use a property. The following example uses a property that logs a message to the console when it’s accessed:
# property_decorator.py
class Foo():
@property
def attribute1(self) -> object:
print("accessing the attribute to get the value")
return 42 @attribute1.setter
def attribute1(self, value) -> None:
print("accessing the attribute to set the value")
raise AttributeError("Cannot change the value")my_foo_object = Foo()
x = my_foo_object.attribute1
print(x)
The example above makes use of decorators to define a property, but as you may know, decorators are just syntactic sugar. The example before, in fact, can be written as follows:
# property_function.py
class Foo():
def getter(self) -> object:
print("accessing the attribute to get the value")
return 42 def setter(self, value) -> None:
print("accessing the attribute to set the value")
raise AttributeError("Cannot change the value") attribute1 = property(getter, setter)my_foo_object = Foo()
x = my_foo_object.attribute1
print(x)
Now you can see that the property has been created by using property()
. The signature of this function is as follows:
property(fget=None, fset=None, fdel=None, doc=None) -> object
property()
returns a property
object that implements the descriptor protocol. It uses the parameters fget
, fset
and fdel
for the actual implementation of the three methods of the protocol.
Python Descriptors in Methods and Functions
If you’ve ever written an object-oriented program in Python, then you’ve certainly used methods. These are regular functions that have the first argument reserved for the object instance. When you access a method using dot notation, you’re calling the corresponding function and passing the object instance as the first parameter.
The magic that transforms your obj.method(*args)
call into method(obj, *args)
is inside a .__get__()
implementation of the function
object that is, in fact, a non-data descriptor. In particular, the function
object implements .__get__()
so that it returns a bound method when you access it with dot notation. The (*args)
that follow invoke the functions by passing all the extra arguments needed.
To get an idea for how it works, take a look at this pure Python example from the official docs:
import typesclass Function(object):
...
def __get__(self, obj, objtype=None):
"Simulate func_descr_get() in Objects/funcobject.c"
if obj is None:
return self
return types.MethodType(self, obj)
In the example above, when the function is accessed with dot notation, .__get__()
is called and a bound method is returned.
This works for regular instance methods just like it does for class methods or static methods. So, if you call a static method with obj.method(*args)
, then it’s automatically transformed into method(*args)
. Similarly, if you call a class method with obj.method(type(obj), *args)
, then it’s automatically transformed into method(type(obj), *args)
.
Note: To learn more about *args
, check out Python args and kwargs: Demystified.
In the official docs, you can find some examples of how static methods and class methods would be implemented if they were written in pure Python instead of the actual C implementation. For instance, a possible static method implementation could be this:
class StaticMethod(object):
"Emulate PyStaticMethod_Type() in Objects/funcobject.c"
def __init__(self, f):
self.f = f def __get__(self, obj, objtype=None):
return self.f
Likewise, this could be a possible class method implementation:
class ClassMethod(object):
"Emulate PyClassMethod_Type() in Objects/funcobject.c"
def __init__(self, f):
self.f = f def __get__(self, obj, klass=None):
if klass is None:
klass = type(obj)
def newfunc(*args):
return self.f(klass, *args)
return newfunc
Note that, in Python, a class method is just a static method that takes the class reference as the first argument of the argument list.