Classes ======= .. admonition:: Precap Classes are a named mix of variables and functions. They are used to instantiate (= create) objects. The concept behind it is also called object orientated programming (OOP) and though it might not be easy to understand fully at first sight, it is very widespread and useful. Actually almost everything within Python is defined by a class. While writing a program, even simple projects might require a lot of code. Usually there are also many solutions to a single task. While comparing different solutions some of the criteria might be * Does the code repeat itself a lot? *Less repetitions are preferred.* * Is it maintainable? *Keeping statements that are related to each other close together is preferred.* Imagine a program, that involves storing the dimensions of rectangles and calculating their areas. There are quite a few different approaches thinkable, but classes are the best solution. Two rectangles -------------- .. interactive_code_block:: :caption: Version 1: Using a separate variables for everything r1_width = 10 r1_height = 20 r1_area = r1_width * r1_height r2_width = 20 r2_height = 30 r2_area = r2_width * r2_height print(' Shape | Width | Height | Area ') print('------------+--------+--------+--------') print('Rectangle 1 | {:6.2f} | {:6.2f} | {:6.2f}'.format(r1_width, r1_height, r1_area)) print('Rectangle 2 | {:6.2f} | {:6.2f} | {:6.2f}'.format(r2_width, r2_height, r2_area)) This version is the simplest one. It uses 6 lines of code (+ 5 for the print statements) and 6 variables appearing 16 times. If task would require 100 rectangles the code for this version would be very long and thus hard to read and maintain. .. interactive_code_block:: :caption: Version 2: Using an array to combine width & height into one variable per rectangle r1 = [10, 20] r1_area = r1[0] * r1[1] r2 = [20, 30] r2_area = r2[0] * r2[1] print(' Shape | Width | Height | Area ') print('------------+--------+--------+--------') print('Rectangle 1 | {:6.2f} | {:6.2f} | {:6.2f}'.format(r1[0], r1[1], r1_area)) print('Rectangle 2 | {:6.2f} | {:6.2f} | {:6.2f}'.format(r2[0], r2[1], r2_area)) This version uses 4 lines of code (2 less than version 1), but is hard to read because statements like ``r1 = [10, 20]`` are not self-explanatory at all, though this could be fixed with some comments in the code. .. interactive_code_block:: :caption: Version 3a: Using dictionaries instead of arrays r1 = { 'width': 10, 'height': 20} r1['area'] = r1['width'] * r1['height'] r2 = { 'width': 20, 'height': 30} r2['area'] = r2['width'] * r2['height'] print(' Shape | Width | Height | Area ') print('------------+--------+--------+--------') print('Rectangle 1 | {:6.2f} | {:6.2f} | {:6.2f}'.format(r1['width'], r1['height'], r1['area'])) print('Rectangle 2 | {:6.2f} | {:6.2f} | {:6.2f}'.format(r2['width'], r2['height'], r2['area'])) This version uses the same amount of lines like version 2. The circumstance that the statements are longer is not a disadvantage here since they are more self-explanatory. .. interactive_code_block:: :caption: Version 3b: Using the double asterisk ** to unpack the dictionary and shorten the lines in the print statements r1 = { 'width': 10, 'height': 20} r1['area'] = r1['width'] * r1['height'] r2 = { 'width': 20, 'height': 30} r2['area'] = r2['width'] * r2['height'] print(' Shape | Width | Height | Area ') print('------------+--------+--------+--------') print('Rectangle 1 | {width:6.2f} | {height:6.2f} | {area:6.2f}'.format(**r1)) print('Rectangle 2 | {width:6.2f} | {height:6.2f} | {area:6.2f}'.format(**r2)) Unpacking dictionaries (= preceding the identifier with a double asterisk ``**``) is a *very* short and convienient way to in fact write ``.format(width=r1['width'], height=r1['height'], area=r1['area'])``. .. interactive_code_block:: :caption: Version 4a: Using a dictionaries but replacing the calculation with a function def rectangle_area(w, h): return w * h r1 = { 'width': 10, 'height': 20} r1['area'] = rectangle_area(r1['width'], r1['height']) r2 = { 'width': 20, 'height': 30} r2['area'] = rectangle_area(r2['width'], r2['height']) print(' Shape | Width | Height | Area ') print('------------+--------+--------+--------') print('Rectangle 1 | {width:6.2f} | {height:6.2f} | {area:6.2f}'.format(**r1)) print('Rectangle 2 | {width:6.2f} | {height:6.2f} | {area:6.2f}'.format(**r2)) This version adds two lines in comparison to version 3a and 3b, but has the benefit that if the area calculation would have to be changed, it needs to be changed only once. .. interactive_code_block:: :caption: Version 4b: Using the rectangle as function argument instead of its width and height def rectangle_area(r): r['area'] = r['width'] * r['height'] r1 = { 'width': 10, 'height': 20} rectangle_area(r1) r2 = { 'width': 20, 'height': 30} rectangle_area(r2) print(' Shape | Width | Height | Area ') print('------------+--------+--------+--------') print('Rectangle 1 | {width:6.2f} | {height:6.2f} | {area:6.2f}'.format(**r1)) print('Rectangle 2 | {width:6.2f} | {height:6.2f} | {area:6.2f}'.format(**r2)) This version has a bit shorter lines, since the function here takes the dictionary and adds directly the item ``'area'`` to it without returning a result using the ``return`` keyword. Two rectangles using a class ---------------------------- The only way to further improve the preceeding examples is to use classes. Classes are used to instantiate (= create) objects. The relation can also be thought of as a blueprint of a house (= the class definition) and a built house (= the instatiated object). .. interactive_code_block:: :caption: Basic pseudo code of a class class BasicClass(): def __init__(self, argument_2, argument_3, ...): # Statement 1 # Statement 2 self.instance_variable_1 = argument_2 self.instance_variable_2 = argument_3 self.instance_variable_3 = [1, 2, 3] self.instance_variable_4 = {'a': 1, 'b': 2, 'c': 3] def instance_function_1(self, argument_2, argument_3, ...): # Statement 3 # Statement 4 # Statement 5 def instance_function_2(self, argument_2, argument_3, ...): # Statement 6 # Statement 7 identifier = BasicClass(123, 'Abc') The basic definition of a class starts with the ``class`` keyword followed by an identifier as **class name**, which usually starts with a capital letter (by convention). Then, with the indentation defined as part of the class, there is a series of function definitions, where the first one uses the predefined name ``__init__()`` and all functions use ``self`` as their **first argument**. |br| **NOTE**: The pseudo code above will throw an error and syntax coloring also fails. The following mechanisms are important to comprehend: * ``object = BasicClass()`` instantiates (= creates) an object from a class. * The ``__init__()`` function will be called for each object when it is instantiated. * Instance variables for an object can be accessed by ``object_name.instance_variable``. * Instance functions for an object can be accessed by ``object_name.instance_function(argument_2, argument_3, ...)``. * The first argument ``self`` is used as a placeholder within the class definition. * Multiple objects of a class can be instatiated. .. interactive_code_block:: :caption: class BasicClass(): def __init__(self, argument_2): print('New object instantiated.') self.instance_variable_1 = argument_2 def instance_function_name_1(self): print(3 * self.instance_variable_1) object_1 = BasicClass('Hello! ') # Instantiating an object print(object_1.instance_variable_1) # Accessing an instance variable object_1.instance_function_name_1() # Accessing an instance function .. interactive_code_block:: :caption: Version 5a: Using a class for the rectangle's variables and functions # Class definition class Rectangle(): def __init__(self, w, h): # Special method name __init__: this method gets called when an object is created self.width = w # Instance variable (also somtimes called properties) self.height = h def area(self): return self.width * self.height r1 = Rectangle(10, 20) # Creating one object r2 = Rectangle(20, 30) # Creating a second object with different arguments print(' Shape | Width | Height | Area ') print('------------+--------+--------+--------') print('Rectangle 1 | {:6.2f} | {:6.2f} | {:6.2f}'.format(r1.width, r1.height, r1.area())) print('Rectangle 2 | {:6.2f} | {:6.2f} | {:6.2f}'.format(r2.width, r2.height, r2.area())) The indented code within the class definition in this examples consists of two instance function definitions ``def __init__(self, w, h):`` and ``def area(self):``. Within the first one two instance variables ``self.width`` and self.height Special function name ``init()`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The first function in the example with the special name ``__init__(self, w, h):`` will be called once (and only once) for each object when it is created. It is intended to be used for tasks that *initally* setup e.g. variables. |br| The function name is predefined and cannot be changed. Special argument ``self`` ^^^^^^^^^^^^^^^^^^^^^^^^^ The ``self`` argument works as placeholder for an object name within the class definition. It is used to define instance variables by preceding them with ``self.`` and it is supplied as first positional argument to each function definition to be able to access any instance variables or instance functions. |br| The name ``self`` is only a convention and it could actually be replaced by any other valid identifer, but since this would be unexpected and confusing is highly not recommended (other programming languages use eg ``me`` or ``this``). Instance variables ^^^^^^^^^^^^^^^^^^ Instance variables are preceded with ``self.`` and not removed from memory after a function call is finished (and are thus have a longer lifetime than local variables). .. interactive_code_block:: :caption: Version 5b: Same as 5a but adding a the function as_dict(self) to be able to use dictionary unpacking class Rectangle(): def __init__(self, w, h): self.width = w self.height = h def area(self): return self.width * self.height # Return the two class variables and the result of the class function as dictionary def as_dict(self): return {'width': self.width, 'height': self.height, 'area':self.area()} r1 = Rectangle(10, 20) r2 = Rectangle(20, 30) print(' Shape | Width | Height | Area ') print('------------+--------+--------+--------') print('Rectangle 1 | {width:6.2f} | {height:6.2f} | {area:6.2f}'.format(**r1.as_dict())) print('Rectangle 2 | {width:6.2f} | {height:6.2f} | {area:6.2f}'.format(**r2.as_dict()))