# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python [Root]
#     language: python
#     name: Python [Root]
# ---

# # Slots

# The attributes of objects are stored in a dictionary `__dict__`. Like any other dictionary, a dictionary used for attribute storage doesn't have a fixed number of elements. In other words, you can add elements to dictionaries after they have been defined.
#
# This is the reason, we can dynamically add attributes to objects of classes that we have created.
#

>>> class Foo(object):
...     pass

a = Foo()

a.x = 128

a.y = "Dynamically created Attribute!"

a.__dict__

import pprint
pprint.pprint(a.__class__.__dict__)

# ## We can't do this with built-in classes like **`int`**, or **`list`**.

a = int()

a.x = 5

a = dict()

a.y = 5

# Using a dictionary for attribute storage is very convenient, but it can mean a waste of space for objects, which have only a small amount of instance variables. The space consumption can become critical when creating large numbers of instances. Slots are a nice way to work around this space consumption problem. Instead of having a dynamic dict that allows adding attributes to objects dynamically, slots provide a static structure which prohibits additions after the creation of an instance.
#
# When we design a class, we can use slots to prevent the dynamic creation of attributes. To define slots, you have to define a list with the name `__slots__`. The list has to contain all the attributes, you want to use. We demonstrate this in the following class, in which the slots list contains only the name for an attribute `val`.

>>> class Foo(object):
...     __slots__ = ['val']
...     def __init__(self, v):
...         self.val = v

x = Foo(42)

x.val = 100
x.val

x.new = "not possible"

# # Relationship between Class and type

x = [4, 5, 9]

y = "Hello"

print(type(x), type(y))

# If you apply type on the name of a class itself, you get the class **`type`** returned.

print(type(list), type(str)) # This is similar to applying type on type(x) and type(y)

print(type(type(x)), type(type(y)))

# # Classes and Class Creation

# `type(classname, superclasses, attributes_dict)`

>>> class Foo(object):
...     pass

a = Foo()

type(a)

type(type(a))

Foo = type("Foo", (), {})

x = Foo()

print(type(x))

# When we call **`type`**, the call method of type is called. The call method runs two other methods: **`new`** and **`init`**
# ```
# type.__new__(typeclass, classname, superclasses, attributedict)
# type.__init__(cls, classname, superclasses, attributedict)
# ```
#
#
# **Class creating without **

>>> class ObjectWithOutType(object):
...     counter = 0
...     def __init__(self, name):
...         self.name = name
...     def about(self):
...         return 'This is test hello!' + self.name

y = ObjectWithOutType('without')

print(y.name)

print(y.about())

print(y.__dict__)

# The **new** method creates and returns the **new** class object, and after this the **new** method initializes the newly created object.

>>> def object_init(self, name):
...     self.name = name
>>> ObjectWithType = type('ObjectWithType',
...                       (),
...                       {'counter': 0,
...                       '__init__': object_init,
...                       'about': lambda self: 'This is from Object with type! ' + self.name})

x = ObjectWithType('with')

print(x.name)

print(x.__dict__)

import pprint
pprint.pprint(x.__class__.__dict__)
pprint.pprint(y.__class__.__dict__)

# # More metaclasses

# +
>>> class BaseClass1:
...     def ret_method(self, *args):
...         return 5

>>> class BaseClass2:
...     def ret_method(self, *args):
...         return 5
# -

a = BaseClass1()
a.ret_method()

b = BaseClass2()
b.ret_method()

# **Another way to achieve this**

# +
>>> class SuperClass(object):
...     def ret_method(self, *args):
...         return 5

>>> class BaseClass1(SuperClass):
...     pass

>>> class BaseClass2(SuperClass):
...     pass
# -

a = BaseClass1()
a.ret_method()

b = BaseClass2()
b.ret_method()

# Let's assume, we don't know a previously if we want or need this method. Let's assume that the decision, if the classes have to be augmented, can only be made at runtime. This decision might depend on configuration files, user input or some calculations.

required = True

>>> def ret_method(self, *args):
...     return 5

>>> class BaseClass1(object):
...     pass
>>> if required:
...     BaseClass1.ret_method = ret_method
... class BaseClass2(object):
...     pass
>>> if required:
...     BaseClass2.ret_method = ret_method

a = BaseClass1()
a.ret_method()

b = BaseClass2()
b.ret_method()

# We have to add the same code to every class and it seems likely that we might forget it. Hard to manage and maybe even confusing, if there are many methods we want to add.

required = True


def ret_method(self, *args):
    return 5
def augment_method(cls):
    if required:
        cls.ret_method = ret_method
    return cls
class BaseClass1(object):
    pass
augment_method(BaseClass1)
class BaseClass2(object):
    pass
augment_method(BaseClass2)

a = BaseClass1()
a.ret_method()

b = BaseClass2()
b.ret_method()

# This is good but we need to make sure the code is executed.
#
#
# The code should be executed **automatically**. We need a way to make sure that "some" code might be executed automatically after the end of a class definition.

required = True

>>> def ret_method(self, *args):
...     return 5
>>> def augment_method(cls):
...     if required:
...         print('Adding')
...         cls.ret_method = ret_method
...     return cls
>>> @augment_method
... class BaseClass1:
...     pass
>>> @augment_method
... class BaseClass2:
...     pass
# BaseClass1 = augment_method(BaseClass1)
# BaseClass2 = augment_method(BaseClass2)

a = BaseClass1()
a.ret_method()

b = BaseClass2()
b.ret_method()

# # Singleton

# +
>>> class Singleton(type):
...     _instances = {}
...     def __call__(cls, *args, **kwargs):
...         if cls not in cls._instances:
...             cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
...         return cls._instances[cls]

>>> class SingletonClass(metaclass=Singleton):
...     pass

>>> class RegularClass():
...     pass
# -

x = SingletonClass()

y = SingletonClass()

print(x == y)

x = RegularClass()

y = RegularClass()

print(x == y)

print(id(x), id()

StarcoderdataJupyterScriptsDedupFiltered