Introduction to Python Programming - Classes
Published: 2024-02-23 19:40:34

## Table of Contents
<a id="table-of-contents"></a>

  - [Table of Contents](#table-of-contents)
  - [Introduction](#introduction)
  - [Creating a Class](#creating-a-class)
  - [Creating Instance Objects](#creating-instance-objects)
  - [Class and Instance Variables](#class-and-instance-variables)
  - [Using Dunder init for Class Invariance](#using-dunder-init-for-class-invariance)
  - [Class and Instance Methods](#class-and-instance-methods)
  - [Static Methods](#static-methods)
  - [Data and Implementation Hiding](#data-and-implementation-hiding)
  - [Inheritance](#inheritance)
    - [Single Inheritance](#single-inheritance)
    - [The super() Function](#the-super-function)
    - [Multiple Inheritance](#multiple-inheritance)
  - [Printing and Debugging An Object](#printing-and-debugging-an-object)
  - [Data Classes](#data-classes)
  - [Summary](#summary)

## Introduction
<a id="introduction"></a>

Classes are a classic feature of objected-oriented programming, a programming paradigm that structures code as objects with attributes (variables) and behavior (functions or methods). The attributes and functions usually form a single logical unit. For example, you may define a `Person` class to represent the attributes and behavior of a person.

You can also see a class as a blueprint for creating objects, similar to how architectures are blueprints for making concrete buildings. The objects are called instances of that class.

Unlike object-oriented languages like Java, Python lets you use classes or not. You can choose between using the variables and functions individually or group them under a class.

Before proceeding, I assume you understand basic Python programming, including variables and functions.

## Creating a Class
<a id="creating-a-class"></a>

Use the `class` keyword followed by the class name and a colon. For example:

```python
class Restaurant:
    # ...
```

> While you can use any valid naming pattern to define your class names, by convention, classes are defined using Pascal cases. For example, Animal, MyBook, ItalianRestaurant, etc.

Next, include the attributes and methods that are either attached to the class itself or the instances of the class. For example:

```python
class Restaurant:
    """A restaurant class"""
    
    count: int = 0

    def __init__(self) -> None:
        self.name: str = ''
        self.address: str = ''
        self.type: str = ''
        self.capacity: int = 1
        self.spacing: float = 1.0


    def get_name(self) -> str:
        return self.name
    
    def set_name(self, name: str) -> None:
        self.name = name
    
    @classmethod
    def increase_count(cls) -> None:
        cls.count += 1


    @staticmethod
    def describe() -> None:
        print('My awesome Restaurant')
```

The `Restaurant` class has the following:

- A docstring `"""A restaurant class"""` accessible through `__doc__` attribute
- A class attribute `count`, an integer directly attached to the `Restaurant` class and shared by all instance objects.
- An `__init__()` method where instance variables are created and or initialized
- Five instance attributes or variables - `name`, `address`, `type`, `capacity`, and `spacing` are attached to each unique instance of the `Restaurant` class.
- Two instance methods - `get_name()` and `set_name()`. An instance method must take itself  (`self`) as its first argument.
- A class method `increase_count()` which is attached to the `Restaurant` class. A class method must take the class itself as its first argument.
- An independent static method `describe()` which is in the class basically for the sake of grouping. This means that static methods can exist as regular functions outside the class. Note that you can access a static method via the class object and an instance object.

In Python, a class, like its instances, is also an object.

Defining a class automatically a class object. You can access its attributes using the `ClassName.name` syntax. For example:

```python
# Print some of the class attributes
print(Restaurant.count)
print(Restaurant.__doc__)
print(Restaurant.increase_count)
print(Restaurant.describe)

# You can also add new attributes to a class
# However, do not do this. It is better to include
# your variables in the class definition
Restaurant.age = 10
print(Restaurant.age)

# Delete the added attribute 
del Restaurant.age
# print(Restaurant.age) # Throws AttributeError

# call the class method
Restaurant.increase_count()
print(Restaurant.count)

# call the static method
Restaurant.describe()
```

```cmd
0
A restaurant class
<bound method Restaurant.increase_count of <class '__main__.Restaurant'>>
<function Restaurant.describe at 0x000002882CECE3E0>
1
My awesome Restaurant
```

> **0x000002882CECE3E0** is the current memory address of the function `Restaurant.describe()`. The memory address usually changes each time you run the Python program.

## Creating Instance Objects
<a id="creating-instance-objects"></a>

You can also create an instance object using the `ClassName()` syntax. For example:

```python
italian_rest = Restaurant()

# Print the instance object
# Prints the memory address at the time of creation
print(italian_rest)
# Check the type
print(type(italian_rest))

# Create another independent instance of Restaurant
chinese_res = Restaurant()
print(type(chinese_rest))

# Instances are independent or unique
print(italian_rest == chinese_res)
```

```cmd
<__main__.Restaurant object at 0x00000229E1AB78F0>
<class '__main__.Restaurant'>
<class '__main__.Restaurant'>
False
```

Checking the types shows that both `italian_rest` and `chinese_rest` objects are `Restaurant` objects. Also, you can use the built-in `isinstance()` function to determine if an object was created from a class. For example:

```python
print(isinstance(italian_rest, Restaurant))
print(isinstance(chinese_res, Restaurant))
print(isinstance(chinese_res, list))
```

```cmd
True
True
False
```

The instance variables and methods (including static methods) can be accessed using the `instance.name` syntax. For example:

```python
print(italian_rest.capacity)
print(italian_rest.spacing)

italian_rest.describe()
print(italian_rest.name) # empty string
italian_rest.set_name('Italian restaurant')
print(italian_rest.name)
```

```cmd
1
1.0
My awesome Restaurant

Italian restaurant
```

## Class and Instance Variables
<a id="class-and-instance-variables"></a>

Looking at the `Restaurant` class, `count` is a class variable while `name`, `address`, and so on are instance variables.

All instances of a class share class variables while instance variables are unique to each instance object created from the class. Each time you create a new instance of `Restaurant`, a call to `Restaurant.increase_count()` or `self.increase_count()` within the `__init__()` method increases `count` by one.

```python
class Restaurant:
    """A restaurant class"""
    
    count: int = 0

    def __init__(self) -> None:
        self.name: str = ''
        self.address: str = ''
        self.type: str = ''
        self.capacity: int = 1
        self.spacing: float = 1.0

        Restaurant.increase_count()
        # self.increase_count()

    # ... Elided for brevity


italian = Restaurant()
chinese = Restaurant()

print(Restaurant.count) # 2
print(italian.count) # 2
print(chinese.count) # 2
```

Note that while `count` also appears to be accessible via the instances - `chinese` and `italian`, it's not an instance variable. The `__dict__` attribute (a dictionary) of an instance contains the variables attached to an instance.

```python
print(italian.__dict__)
print(chinese.__dict__)
```

```cmd
{'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0}
{'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0}
```

`count` is not in `__dict__`. You could "hide" or override a class variable by adding the same variable directly to an instance object. **Don't do this**:

```python
italian.count = 3

print(italian.count) # 3
print(chinese.count) # 2
print(italian.__dict__)
print(chinese.__dict__)

```

A new instance variable `count` on the `italian` instance now overshadows the class variable `count`.

```cmd
3
2
{'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0, 'count': 3}
{'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0}
```

Deleting the instance `count` makes the class `count` re-appear.

```python
del italian.count
print(italian.count) # 2
print(italian.__dict__)
# {'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0}
```

In Python, instance variables, like class variables, must be initialized upon creation inside the `__init__()` method. The string variables such as `name` are currently empty strings `''` while `capacity` and `spacing` are `int` and `float` types, respectively. You could modify the instance variables after creation. For example:

```python
from pprint import pp # pretty print

print(italian.name)
print(italian.address)

italian.name = 'Francesco T. Burgers'
italian.address = "21st Navy Avenue, Italy."
italian.capacity = 80
italian.type = 'Italian restaurant'
italian.spacing = 18.2

print(italian.__dict__)
print(italian.name)
print(italian.address)
print(italian.capacity)
```

```cmd


{'name': 'Francesco T. Burgers',       
 'address': '21st Navy Avenue, Italy.',
 'type': 'Italian restaurant',
 'capacity': 80,
 'spacing': 18.2}
Francesco T. Burgers
21st Navy Avenue, Italy.
80
```

Modifying each instance variable feels like a lot of work. Luckily, the `__init__()` can take any number of arguments you can use to set instance variables. For example:

```python
class Restaurant:
    # ...
    
    def __init__(self, name: str, address: str, type: str,
                 capacity: int = 1, spacing: float = 1.0):
        self.name = name
        self.address = address
        self.type = type
        self.capacity = capacity
        self.spacing = spacing

        Restaurant.increase_count()

    # ...
```

You can now create an instance and set appropriate values upon creation. Recreating the `italian` restaurant you get:

```python
italian = Restaurant('Francesco T. Burgers', '21st Navy Avenue, Italy.',
                     'Italian restaurant', 80, 18.2)

print(italian.name)
print(italian.address)
print(italian.capacity)
print(italian.type)
print(italian.spacing)
```

```cmd
Francesco T. Burgers
21st Navy Avenue, Italy.
80
Italian restaurant
18.2
```

How about the `chinese` instance:

```python
chinese = Restaurant('Jet Li Spot', '31st Broadway Palace',
                     'Chinese restaurant', 80, 16.2)


print(chinese.name)
print(chinese.address)
print(chinese.capacity)
print(chinese.type)
print(chinese.spacing)
```

```cmd
Jet Li Spot
31st Broadway Palace
80
Chinese restaurant
16.2
```

## Using Dunder init for Class Invariance
<a id="using-dunder-init-for-class-invariance"></a>

You can also set constraints on instantiating a class in the `__init__()` method. For example, it might not make sense for a restaurant to have no name or address or have a capacity or spacing of zero. You can include checks to ensure that nobody can create such a class.

The concept is known as establishing class invariance. A state where an object of a class meets certain expectations that never change throughout its lifetime. For example, we use the `__init__()` to ensure `Restaurant`s are never created with empty strings or with zero(0) and negative spacing and capacity.

```python
class Restaurant:
    """A restaurant class"""

    count = 0

    def __init__(self, name: str, address: str, type: str,
                 capacity: int = 1, spacing: float = 1.0):
        
        invalid_name = self._invalid_str(name)
        invalid_address = self._invalid_str(address)
        invalid_type = self._invalid_str(type)
        
        if invalid_name or invalid_address or invalid_type:
            raise ValueError('Please provide a non-empty string for name, address and type')
        
        if capacity <= 0 or spacing <= 0:
            raise ValueError('capacity and spacing cannot be zero or less')
        

        self.name = name
        self.address = address
        self.type = type
        self.capacity = capacity
        self.spacing = spacing

        self.increase_count()

    # ...

    def set_name(self, name):
        if self._invalid_str(name):
            raise ValueError('Name must be string and cannot be empty')
        self.name = name


    def _invalid_str(self, value):
        return not isinstance(value, str) or value == ''

    # ... Elided for brevity
```

The `set_name()` method also has a check to ensure non-violation of the class invariance.

```python
# Works
Restaurant('Jet Li Spot', '31st Broadway Palace',
                     'Chinese restaurant', 80, 16.2)

# Invariance violated
# Fails: empty name, address
Restaurant('', '', 'Italian', 18, 17.3)
# ValueError: Please provide a non-empty string for name, address, and type

# Invariance violated
# Fails: capacity is 0
Restaurant('Jet Li Spot', '31st Broadway Palace',
                     'Chinese restaurant', 0, 16.2)
# ValueError: capacity and spacing cannot be zero or less
```

## Class and Instance Methods
<a id="class-and-instance-methods"></a>

A class method, like a class variable, is associated with a class. The method has an annotation `@classmethod` on top of it. Its first argument is the class itself. See the `increase_count(cls, by)` method defined earlier. You can use the word **cls** as the first argument since the `class` keyword is reserved.

You can use `ClassName.class_method()` within or outside the class or  `self.class_method()` within the class. For example:

```python
class Restaurant:
    # ... Elided for brevity

    def __init__(
        #  ... Elided for brevity
        ):

        # ... Elided for brevity

        # Restaurant.increase_count()
        self.increase_count()

    @classmethod
    def increase_count(cls):
        cls.count += 1


Restaurant.increase_count()
chinese.increase_count()
italian.increase_count()

print(Restaurant.count) # 5
```

As you've seen above, each instance can also call a class method. However, all class instances share the result of the call.

## Static Methods
<a id="static-methods"></a>

A method marked with `@staticmethod` is a static method. As stated earlier, a static method can exist as a function outside a class. While a class or instance can call a static method, for example, `MyClass.static_method()` or `my_instance.static_method()`, the static method does not take the class `cls` or instance `self` as its first argument.

Essentially, it is not necessary to have a static method in a class. For example, the `describe()` method could be a regular function in the module:

```python
def describe() -> None:
    print('My awesome Restaurant')

class Restaurant:
    # ...
```

## Data and Implementation Hiding
<a id="data-and-implementation-hiding"></a>

Unlike Python, some programming languages like C#, Java, and Cpp have public, private, and protected variables or methods. This helps programmers hide data (private variables) and implementation details (through private methods). To mimic this behavior, many Python programmers, by convention, prefix "private" variables and methods with underscores (`_`). For example:

```python
class MyClass:

    def __init__():
        self._my_private_variable = 0


    def _my_private_method(self):
        pass

```

While all instance methods and variables (whether prefixed or not) can still be accessed or modified. However, many people follow the convention. That is usually enough.

## Inheritance
<a id="inheritance"></a>

A class can inherit attributes and behavior from another class. This is known as inheritance in object-oriented programming. You usually want to do this to extend or modify the behavior of a class. The class inheriting is referred to as a "child" or "sub" or "derived" class while its parent, the "parent" or "base" class.

In Python, a base class can have one or multiple children or subclasses (single inheritance). A child class can have multiple parents (multiple inheritance). The syntax for inheritance in Python is `class Child(ParentA, ..., ParentZ):`

### Single Inheritance
<a id="single-inheritance"></a>

A class class can inherit from a single parent. All classes you create implicitly inherit from the built-in `object` class. This means that `class Restaurant:` is equivalent to `class Restaurant(object):`. For example:

```python
class Restaurant(object):
    # ...
```

Some people like to be explicit, but I don't think this is necessary.

Add a `cook_meal()` method to the `Restaurant` class. You will expect the implementation of this method in an `italian` instance to be different from a `chinese` instance or any other instance since they serve different meals.

```python
class Restaurant:
    # ...

    def cook_meal():
        # raise NotImplementedError so that each
        # subclass must override this method
        raise NotImplentedError

```

While you could use an `if statement` that checks the `type` of a restaurant, the easiest way to specialize the `cook_meal()` is to create subclasses representing each kind of restaurant. For example:

```python
class ItalianRestaurant(Restaurant):

    def cook_meal(self):
        self._make_italian_dishes()
        

    def _make_italian_dishes(self):
        """Meant to be private"""
        print("I am making Carbonara, Lasagna, and Pizza!")
    

class ChineseRestaurant(Restaurant):

    def cook_meal(self):
        self._make_chinese_dishes()


    def _make_chinese_dishes(self):
        print("I am making Kung Pao Chicken, Fried Rice, and Hot Pot!")
    

class NigerianRestaurant(Restaurant):

    def cook_meal(self):
        self._make_nigerian_dishes()
        

    def _make_nigerian_dishes(self):
        print("I am making Egusi soup, Pounded yam, and Pepper soup")
```

You can create new instances like you would a `Restaurant` class. The variable initialization stays the same. For example:

```python
italian = ItalianRestaurant('Francesco T. Burgers', '21st Navy Avenue, Italy.',
                     'Italian restaurant', 80, 18.2)

chinese = ChineseRestaurant('Jet Li Spot', '31st Broadway Palace, China.',
                     'Chinese restaurant', 80, 16.2)


print(type(italian))
print(isinstance(italian, Restaurant))
print(isinstance(italian, ItalianRestaurant))
print(issubclass(ItalianRestaurant, Restaurant))

print(type(chinese))
print(isinstance(chinese, Restaurant))
print(isinstance(chinese, ChineseRestaurant))
print(issubclass(ChineseRestaurant, Restaurant))

```

```cmd
<class '__main__.ItalianRestaurant'>
True
True
True
<class '__main__.ChineseRestaurant'>
True
True
True
```

> Like the `isinstance()` function, you can use the `issubclass()` function to check if a class is a subclass.

You can now call the `cook_meal()` of each instance to get specialized meals. For example:

```python
italian.cook_meal()
chinese.cook_meal()
```

```cmd
I am making Carbonara, Lasagna, and Pizza!
I am making Kung Pao Chicken, Fried Rice, and Hot Pot!
```

Notice that each subclass has its own `_make_<country>_dishes()` unique to that class. The other variables and methods are inherited as is:

```python
print(chinese.address)
print(chinese.type)
print(chinese.spacing)
print(chinese.get_name())
chinese.describe()
```

```cmd
31st Broadway Palace, China.
Chinese restaurant
16.2
Jet Li Spot
My awesome Restaurant
```

Inheritance can be as deep as you want. That is to say that a child class can also be the base class of another class. For example:

```python
class RomanRestaurant(ItalianRestaurant):
    pass


roman = RomanRestaurant('Roman T. Burgers', '21st Navy Avenue, Rome.',
                            'Italian restaurant', 80, 18.2)

# Type and instance checks
print(type(roman))
print(isinstance(roman, Restaurant))
print(isinstance(roman, ItalianRestaurant))
print(isinstance(roman, RomanRestaurant))

# Subclass checks
print(issubclass(RomanRestaurant, ItalianRestaurant))
print(issubclass(RomanRestaurant, Restaurant))
```

```cmd
<class '__main__.RomanRestaurant'>
True
True
True
True
True
```

> Too many levels deep can be problematic. Remember to keep it simple.

### The super() Function
<a id="the-super-function"></a>

What if you need to add new instance variables to a derived class or call a parent class method within a derived class? Enter the `super()` function. For example:

```python
class ParentClass:

    def __init__(self, name, age) -> None:
        self.name = name
        self.age = age


    def talk(self):
        print(f'My name is {self.name} and I am {self.age} years old!')


    def increase_age(self, by = 1):
        self.age += by



class ChildClass(ParentClass):

    def __init__(self, name, age, some_other_variable) -> None:
        # call the ParentClass __init__()
        super().__init__(name, age)

        self.some_other_variable = some_other_variable


    def talk(self):
        if self.age >= 2:
            super().talk()
        else:
            print('I cannot talk yet!')



father = ParentClass('John Doe', 945)
son = ChildClass('Peter Doe', 1, 'Whatever you like')

father.talk()
son.talk()
son.increase_age()
son.talk()
```

```cmd
My name is John Doe and I am 945 years old!
I cannot talk yet!
My name is Peter Doe and I am 2 years old!
```

- `ChildClass` derives from `ParentClass`.
- Using `super()`, the `ChildClass` calls `ParentClass`'s `__init__()` method to initialize the `name` and `age` instance variables. It (`ChildClass`) then creates and initializes `some_other_variable` specific to itself.
- Within the overridden `talk()` method, the `ChildClass` calls the parents `ParentClass`'s `talk()` method through the `super()` function if `age` is 2 or greater.

The `super()` function gives you access to the immediate base class. Let's add a `GrandChildClass` to the mix.

```python
class GrandChildClass(ChildClass):

    def __init__(self, name, age, some_other_variable) -> None:
        super().__init__(name, age, some_other_variable)

    def talk(self):
        super().talk()
        print('I am a grandchild')


grandson = GrandChildClass("JC", 2, "Whatever")

grandson.talk()
```

```cmd
My name is JC and I am 2 years old!
I am a grandchild
```

The `super()` in the grandchild points to `ChildClass`.

### Multiple Inheritance
<a id="multiple-inheritance"></a>

As stated earlier, a derived class can inherit from multiple base classes. For example, the `StudentWorker` class inherits from both `Student` and `Worker` classes.

```python
class Student:

    def __init__(self, school) -> None:
        self.school = school

    def read(self, hours=1):
        print(f'I have read for {hours} hours!')

    def describe(self):
        print(f"I am a student at {self.school}")


class Worker:

    def __init__(self, occupation) -> None:
        self.occupation = occupation

    def work(self, hours=1):
        print(f'I have worked for {hours} hours!')

    def describe(self):
        print(f"I work as a {self.occupation}")


class StudentWorker(Student, Worker):

    def __init__(self, school, occupation) -> None:
        Student.__init__(self, school) # same as super().__init__(school)
        Worker.__init__(self, occupation)

    def work_and_read(self):
        super().work()
        super().read()
```

A `StudentWorker` has the variables and methods defined in both base classes.

```python
sw = StudentWorker("John Hopkins University", "Bartender")
sw.read()
sw.work()
sw.work_and_read()
```

```cmd
I have read for 1 hours!
I have worked for 1 hours!
I have worked for 1 hours!
I have read for 1 hours!
```

`work()`, `read()`, and `work_and_read()` are unique to each class. So, the outcome is straightforward.

But what happens if the parents have the same methods? For example, both `Student` and `Worker` classes have a `describe()` method. What happens if you call `sw.describe()` or `super().describe()`?

If you guessed "I am a student" you are right! "I am a worker" is not.

```python
sw.describe()
```

```cmd
I am a student at John Hopkins University
```

However, you would get "I am a worker" if you swap the order of inheritance. That is `StudentWorker(Worker, Student)`. The reason is that Python maintains a list of classes it checks (depth-first, then left-right) when searching for a method. This concept is known as **Method Resolution Order (MRO)**.

The first item on this list is the class itself, next are the base classes ordered according to the class definition, and lastly, the `object` class, the mother of all classes. The `mro()` method gives you access to this list. For example:

```python
print(StudentWorker.mro())
```

```cmd
[<class '__main__.StudentWorker'>, <class '__main__.Student'>, <class '__main__.Worker'>, <class 'object'>] 
```

Therefore, when you call `sw.describe()` or `super().describe()` or any method in fact, Python will check in the following order and pick the first match:

- `StudentWorker`
- `Student` and its parents (since Student has its own MRO)
- `Worker` and its parents (since Worker has its own MRO)
- `object`

> If Python does not find a method after these checks, it raises an `AttributeError`. See the article on Exceptions for more details.

Instead of swapping the order of inheritance, you could use the base class directly. For example, `Worker.__init__(self, occupation)` and `Worker.describe(self)` will initialize the occupation variable and call the `Worker`'s `describe()` method, respectively.

## Printing and Debugging An Object
<a id="printing-and-debugging-an-object"></a>

When you print an instance using either `print()`, `str()`, `format()`, or `repr()`, you will get a somewhat cryptic representation of that class or instance. For example:

```python
sw = StudentWorker()

print(sw)
print(str(sw))
print(repr(sw))
print(format(sw))
```

```cmd
<__main__.StudentWorker object at 0x0000027314327D40>
<__main__.StudentWorker object at 0x0000027314327D40>
<__main__.StudentWorker object at 0x0000027314327D40>
<__main__.StudentWorker object at 0x0000027314327D40>
```

Luckily, you can get better and friendlier output by overriding the following methods inherited from the base `object` class:

- `__repr__(self)` - which is called by the `repr()` function as well as the REPL at the command line
- `__str__(self)` - which is called by both the `str()` and `print()` functions
- `__format__(self, format_spec)` which is called by the `format()` function and the string `.format()` method

Let's override only `__repr()__` and `__str()__` since the `format()` function uses `__str__()` if it is present.

```python
class StudentWorker(Student, Worker):
    # ... Elided for brevity
    
    def __repr__(self) -> str:
        return f"StudentWorker(school={self.school}, occupation={self.occupation})"
    

    def __str__(self) -> str:
        return f"Studies at {self.school} and works as a {self.occupation}"
```

We now see a different outcome when we print the object using the same functions mentioned earlier.

```python
sw = StudentWorker("John Hopkins University", "Bartender")

print(repr(sw)) # repr() calls __repr__
print("StudentWorker:", sw)
print(str(sw))
print(format(sw)) # format() calls __str__
print(f"He is serious! He {sw}") # format() calls __str__
```

```cmd
StudentWorker(school=John Hopkins University, occupation=Bartender)
StudentWorker: Studies at John Hopkins University and works as a Bartender
Studies at John Hopkins University and works as a Bartender
Studies at John Hopkins University and works as a Bartender
He is serious! He Studies at John Hopkins University and works as a Bartender
```

- Overriding `__repr__()` is useful for debugging. `StudentWorker(school=John Hopkins University, occupation=Bartender)` is better than `<__main__.StudentWorker object at 0x0000027314327D40>`
- Overriding `__str__()` gives a friendlier message.

## Data Classes
<a id="data-classes"></a>

Data classes are useful for data grouping, representation, or transfer. A data class is a regular class decorated with `dataclasses.dataclass`. Like regular classes, it can take any number of variables (with types specified) and methods and supports single or multiple inheritance.

However, unlike the regular classes, a data class contains default implementations of `__init__()` and `__repr()` methods. For example:

```python
from dataclasses import dataclass, field

@dataclass
class Person:
    name: str
    age: int

    # All keyword variables must come after the positional variables
    # Keyword variables: variables with default arguments
    occupation: str = 'none'
    
    # hobbies: list = [] # FAILs no mutable argument allowed
    hobbies: list[str] = field(default_factory=list)


john = Person('John Doe', 45, 'Bartender')

print(john)
```

```cmd
Person(name='John Doe', age=45, occupation='Bartender', hobbies=[])
```

- `name` and `age` are positional variables
- `occupation` is a keyword variable with a default value of `none`.
- `hobbies` is a list initialized to `[]` by a call `field()`. Direct initialization of mutable data types like `dict` and `list` is not allowed.

The `__init__()`  and `repr()` implemented for you might look like these:

```python
# ... Within the Person class
def __init__(self, name, age, occupation='none', hobbies=[]):
    self.name = name
    self.age = age
    self.occupation = occupation
    self.hobbies = hobbies


def __repr__(self) -> str:
    result = f'{type(self).__name__}(' # Person(
    
    # ', 'join() takes a generator expression
    # Generator expression yields variable=value
    result += ', '.join(f"{k}={v!r}" for k, v in self.__dict__.items())
    result += ')' # close it
        
    return result

# ... Within the Person Class
```

You can get a glimpse of the implemented `__init__()` by looking at its signature.

```python
import inspect

print(inspect.signature(john.__init__))
```

```cmd
(name, age, occupation='none', hobbies=[]) -> None
```

> Feel free to drop the `__repr__(self)` code above in any regular class you create. It just works! The `type(self)__name__` ensures that a subclass returns the appropriate type, which is itself.

Every other thing we've discussed for regular classes applies to data classes. Check out the official Python documentation to learn more about data classes.

Note that Python also supports Enumerations (classes that provide unique values). Check the official documentation also on how to use enumerations.

## Summary
<a id="summary"></a>

In this article, you learn how to create a class and instances of classes. You also learned about class and instance variables and methods. You learned about single and multiple inheritance and how Python searches for methods (and variables). Finally, you saw how to create a data class.

Thank you for reading!