<jupyter_start><jupyter_text>Decorating Classes    Table of Contents * [Decorating the Whole Class](decorating_the_whole_class)    * [Creating Singletons](creating_singletons)* [OOP: Instance Method, Static Method, Class Method](oop:_instance_method,_static_method,_class_method)    * [Instance Method](instance_method)    * [Class Method](class_method)    * [Static Methods](static_methods)    * [Let's See Them in Action](let's_see_them_in_action)    * [Example](example)        * [Delicious Pizza Factories With `@classmethod`](delicious_pizza_factories_with_`@classmethod`)    * [When To Use Static Methods](when_to_use_static_methods)    * [Key Takeaways](key_takeaways)--- There are two different ways you can use decorators on classes.The first one is very close to what you have already done with functions: you can decorate the methods of a class. This was one of the motivations for introducing decorators back in the day.Let’s define a class where we decorate some of its methods using the `@debug` and `@timer` decorators from earlier:<jupyter_code>import functools
import time

def timer(func):
    """Print the runtime of the decorated function"""
    @functools.wraps(func)
    def wrapper_timer(*args, **kwargs):
        start_time = time.perf_counter()    # 1
        value = func(*args, **kwargs)
        end_time = time.perf_counter()      # 2
        run_time = end_time - start_time    # 3
        print(f"Finished {func.__name__!r} in {run_time:.4f} secs")
        return value
    return wrapper_timer
import functools

def debug(func):
    """Print the function signature and return value"""
    @functools.wraps(func)
    def wrapper_debug(*args, **kwargs):
        args_repr = [repr(a) for a in args]                      # 1
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]  # 2
        signature = ", ".join(args_repr + kwargs_repr)           # 3
        print(f"Calling {func.__name__}({signature})")
        value = func(*args, **kwargs)
        print(f"{func.__name__!r} returned {value!r}")           # 4
        return value
    return wrapper_debug
class TimeWaster:
    @debug
    def __init__(self, max_num):
        self.max_num = max_num

    @timer
    def waste_time(self, num_times):
        for _ in range(num_times):
            sum([i**2 for i in range(self.max_num)])
tw = TimeWaster(1000)
tw.waste_time(1000)<jupyter_output>Finished 'waste_time' in 0.1796 secs<jupyter_text>Decorating the Whole Class The other way to use decorators on classes is to decorate the whole class.The other way to use decorators on classes is to decorate the whole class. This is, for example, done in the new [dataclasses module](https://realpython.com/python-data-classes/) in Python 3.7:```pythonfrom dataclasses import dataclass@dataclassclass PlayingCard:    rank: str    suit: str```**Note:** dataclasses will be covered later. The meaning of the syntax is similar to the function decorators. In the example above, you could have done the decoration by writing `PlayingCard = dataclass(PlayingCard)`. Writing a class decorator is very similar to writing a function decorator. The only difference is that the decorator will receive a class and not a function as an argument. In fact, all the decorators you saw already will work as class decorators. When you are using them on a class instead of a function, their effect might not be what you want. In the following example, the `@timer` decorator is applied to a class:<jupyter_code>import functools
import time

def timer(func):
    """Print the runtime of the decorated function"""
    @functools.wraps(func)
    def wrapper_timer(*args, **kwargs):
        start_time = time.perf_counter()    # 1
        value = func(*args, **kwargs)
        end_time = time.perf_counter()      # 2
        run_time = end_time - start_time    # 3
        print(f"Finished {func.__name__!r} in {run_time:.4f} secs")
        return value
    return wrapper_timer


@timer
class TimeWaster:
    def __init__(self, max_num=1000):
        self.max_num = max_num

    def waste_time(self, num_times):
        for _ in range(num_times):
            sum([i**2 for i in range(self.max_num)])
tw = TimeWaster()
tw.waste_time(1000)<jupyter_output>Finished 'TimeWaster' in 0.0000 secs<jupyter_text>Decorating a class does not decorate its methods. Recall that `@timer` is just shorthand for `TimeWaster = timer(TimeWaster)`.`-->` Here, `@timer` only measures the time it takes to instantiate the class:<jupyter_code>tw = TimeWaster(1)
tw = TimeWaster(1000000)<jupyter_output>Finished 'TimeWaster' in 0.0000 secs<jupyter_text>An example defining a proper class decorator is `@singleton` which ensures that there is only one instance of a class.  Creating Singletons A singleton is a class with only one instance. There are several singletons in Python that you use frequently, including `None`, `True`, and `False`. It is the fact that `None` is a singleton that allows you to compare for `None` using the `is` keyword. Using `is` returns `True` only for objects that are the exact same instance. The following `@singleton` decorator turns a class into a singleton by storing the first instance of the class as an attribute. Later attempts at creating an instance simply return the stored instance:<jupyter_code>import functools

def singleton(cls):
    """Make a class a Singleton class (only one instance)"""
    @functools.wraps(cls)
    def wrapper_singleton(*args, **kwargs):
        if not wrapper_singleton.instance:
            print("creating object for the first time...")
            wrapper_singleton.instance = cls(*args, **kwargs)
        return wrapper_singleton.instance

    wrapper_singleton.instance = None
    return wrapper_singleton

@singleton
class TheOne:
    pass<jupyter_output><empty_output><jupyter_text>As you see, this class decorator follows the same template as our function decorators. The only difference is that we are using `cls` instead of `func` as the parameter name to indicate that it is meant to be a class decorator. Let’s see if it works:<jupyter_code>first_one = TheOne()
another_one = TheOne()
id(first_one)
id(another_one)
first_one is another_one<jupyter_output><empty_output><jupyter_text>It seems clear that first_one is indeed the exact same instance as another_one.  OOP: Instance Method, Static Method, Class Method Some commonly used decorators that are even built-ins in Python are `@classmethod` and `@staticmethod`. The `@classmethod` and `@staticmethod` decorators are used to define methods inside a class namespace that are not connected to a particular instance of that class<jupyter_code>class MyClass:
    def method(self):
        return 'instance method called', self

    @classmethod
    def classmethod(cls):
        return 'class method called', cls

    @staticmethod
    def staticmethod():
        return 'static method called'
class Student:
    University = 'University of Alberta'
    def __init__(self, name):
        self.name = name
        
    def get_name(self, name):
        return name
    
    @classmethod
    def get_university(cls):
        return cls("Mohsen")

    @staticmethod
    def cal_area(r):
        return 3.14 * (r ** 2)
obj.add(3, 4)
Student.cal_area(3)
Student.cal_area()<jupyter_output><empty_output><jupyter_text>Instance MethodThe first method on `MyClass`, called `method`, is a regular instance method. That’s the basic, no-frills method type you’ll use most of the time. You can see the method takes one parameter, `self`, which points to an instance of `MyClass` when the method is called (but of course instance methods can accept more than just one parameter). Through the `self` parameter, instance methods can freely access attributes and other methods on the same object. This gives them a lot of power when it comes to modifying an object’s state. Not only can they modify object state, instance methods can also access the class itself through the `self.__class__` attribute. This means instance methods can also modify class state.  Class MethodLet’s compare that to the second method, `MyClass.classmethod`. I marked this method with a `@classmethod` decorator to flag it as a class method.Instead of accepting a `self` parameter, class methods take a `cls` parameter that points to the class—and not the object instance—when the method is called.Because the class method only has access to this `cls` argument, it can’t modify object instance state. That would require access to `self`. However, class methods can still modify class state that applies across all instances of the class.  Static MethodsThe third method, `MyClass.staticmethod` was marked with a `@staticmethod` decorator to flag it as a static method.This type of method takes neither a `self` nor a `cls` parameter (but of course it’s free to accept an arbitrary number of other parameters).Therefore a static method can neither modify object state nor class state. Static methods are restricted in what data they can access - and they’re primarily a way to namespace your methods.  Let's See Them in Action Here’s what happens when we call an **instance method**:<jupyter_code>obj = MyClass()
obj.method()<jupyter_output><empty_output><jupyter_text>This confirmed that `method` (the instance method) has access to the object instance (printed as ``) via the `self` argument. When the method is called, Python replaces the `self` argument with the instance object, `obj`. We could ignore the syntactic sugar of the dot-call syntax (`obj.method()`) and pass the instance object manually to get the same result:<jupyter_code>MyClass.method(obj)<jupyter_output><empty_output><jupyter_text>By the way, instance methods can also access the class itself through the `self.__class__` attribute. This makes instance methods powerful in terms of access restrictions - they can modify state on the object instance and on the class itself. Let’s try out the **class method** next:<jupyter_code>obj.classmethod()<jupyter_output><empty_output><jupyter_text>Calling `classmethod()` showed us it doesn’t have access to the `` object, but only to the `` object, representing the class itself (everything in Python is an object, even classes themselves). Notice how Python automatically passes the class as the first argument to the function when we call `MyClass.classmethod()`. Calling a method in Python through the dot syntax triggers this behavior. The `self` parameter on instance methods works the same way. Please note that naming these parameters `self` and `cls` is just a convention. You could just as easily name them `the_object` and `the_class` and get the same result. All that matters is that they’re positioned first in the parameter list for the method. Time to call the **static method** now:<jupyter_code>obj.staticmethod()<jupyter_output><empty_output><jupyter_text>Behind the scenes Python simply enforces the access restrictions by not passing in the `self` or the `cls` argument when a static method gets called using the dot syntax. This confirms that static methods can neither access the object instance state nor the class state. They work like regular functions but belong to the class’s (and every instance’s) namespace. Now, let’s take a look at what happens when we attempt to call these methods on the class itself - without creating an object instance beforehand:<jupyter_code>MyClass.classmethod()
MyClass.staticmethod()
MyClass.method()<jupyter_output><empty_output><jupyter_text>We were able to call `classmethod()` and `staticmethod()` just fine, but attempting to call the instance method `method()` failed with a `TypeError`. And this is to be expected — this time we didn’t create an object instance and tried calling an instance function directly on the class blueprint itself. This means there is no way for Python to populate the self argument and therefore the call fails.  Example An example using this bare-bones Pizza class:<jupyter_code>class Pizza:
    def __init__(self, ingredients):
        self.ingredients = ingredients

    def __repr__(self):
        return f'Pizza({self.ingredients!r})'<jupyter_output><empty_output><jupyter_text>Delicious Pizza Factories With `@classmethod`If you’ve had any exposure to pizza in the real world you’ll know that there are many delicious variations available:<jupyter_code>Pizza(['mozzarella', 'tomatoes'])
Pizza(['mozzarella', 'tomatoes', 'ham', 'mushrooms'])
Pizza(['mozzarella'] * 4)<jupyter_output><empty_output><jupyter_text>The Italians figured out their pizza taxonomy centuries ago, and so these delicious types of pizzas all have their own names. We’d do well to take advantage of that and give the users of our Pizza class a better interface for creating the pizza objects they crave.A nice and clean way to do that is by using class methods as factory functions for the different kinds of pizzas we can create:<jupyter_code>class Domnios:
    def __init__(self, ingredients):
        self.ingredients = ingredients

    def __repr__(self):
        return f'Pizza({self.ingredients!r})'

    @classmethod
    def margherita(cls):
        return cls(['mozarella', 'ham'])

    @classmethod
    def mushroom_and_cheese(cls):
        return cls(['beef', 'mushroom', 'cheese'])
pizza = Dominos.mushroom_and_cheese()
pizza.ingredients<jupyter_output><empty_output><jupyter_text>Note how I’m using the `cls` argument in the `margherita` and `prosciutto` factory methods instead of calling the `Pizza` constructor directly. This is a trick you can use to follow the [Don’t Repeat Yourself (DRY)](https://en.wikipedia.org/wiki/Don't_repeat_yourself) principle. If we decide to rename this class at some point we won’t have to remember updating the constructor name in all of the classmethod factory functions. Now, what can we do with these factory methods? Let’s try them out:<jupyter_code>Pizza.margherita()
Pizza.prosciutto()<jupyter_output><empty_output><jupyter_text>As you can see, we can use the factory functions to create new `Pizza` objects that are configured the way we want them. They all use the same `__init__` constructor internally and simply provide a shortcut for remembering all of the various ingredients. Another way to look at this use of class methods is that they allow you to define alternative constructors for your classes. Python only allows one `__init__` method per class. Using class methods it’s possible to add as many alternative constructors as necessary. This can make the interface for your classes self-documenting (to a certain degree) and simplify their usage.  When To Use Static Methods<jupyter_code>import math

class Pizza:
    def __init__(self, radius, ingredients):
        self.radius = radius
        self.ingredients = ingredients

    def __repr__(self):
        return (f'Pizza({self.radius!r}, '
                f'{self.ingredients!r})')

    def area(self):
        return self.circle_area(self.radius)

    @staticmethod
    def circle_area(r):
        return r ** 2 * math.pi<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.07743097238895558, 'fraction_numerical': 0.004268374016273176, 'mean_word_length': 4.391945343401654, 'pattern_counts': {'":': 0, '<': 62, '<?xml version=': 0, '>': 63, 'https://': 2, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '16', 'dir': 'jupyter-structured-clean-dedup', 'id': '404719', 'license': 'MIT', 'n_tokens_mistral': 4223, 'n_tokens_neox': 4073, 'n_words': 1798, 'path': 'Python/04. Advanced/03.3 Decorating Classes.ipynb', 'repo_name': 'saramazaheri/CS-Tutorial', 'size': '14994'}