<jupyter_start><jupyter_text>Classes Finally we get to classes.I assume you already have some knowledge of classes and OOP in general, so I'll focus on the semantics of creating classes and some of the differences with Java classes. First, the question of visibility. There is no such thing as private or public in Python. Everything is public, period. So we don't have to specify the visibility of functions and attributes in Python. Class instantiations are done in two steps - the instance is created first, and then the instance is initialized. In general we hook into the initialization phase and leave the object creation alone. We do this by using a special method in the class called `__init__`. We'll see how large a role special methods play in Python. The important thing to note is that by the time `__init__` is called in our class, the object (instance) has **already** been created. We use the `class` keyword to create classes:<jupyter_code>class Person:
    pass<jupyter_output><empty_output><jupyter_text>`pass` is something we can use in Python to indicate "do nothing" (a so called "no-op" operation). Here I use it to supply a body for the class definition, but don't actually want to specify any functionality. So now we can actually create instances of the `Person` object at this point. They will be pretty useless since we have not implemented any functionality yet. We create instances of classes by **calling** the class - remember how we call things in Python, we use `()`:<jupyter_code>p = Person()
id(p), type(p)<jupyter_output><empty_output><jupyter_text>So now we may want to add some functions to the class. Whenever we define a function in a class, we have to understand what happens when we call that function from an instance, using dot notation: For example, if we write ```p.say_hello()```then we are calling the `say_hello()` function from the instance, and Python will **bind** that function to the specific instance - i.e. it creates an association between the instance used to call the function, and the function. The way it does this is by passing in the instance reference to the function as the first positional argument - in this case it would actually call `say_hello(p)`. And our `say_hello` function now has access to the instance it was called from, including any internal state.When functions are bound to an instance, they are called **methods** of the class, and, in particular **instance methods** because they are bound to instances of the class when called. (There are other types of functions that can be bound to the class, called *class methods*, but this is beyond the scope of this primer). Let's see how this works:<jupyter_code>class Person:
    def say_hello(instance, name):
        return f'{instance} says hello to {name}'<jupyter_output><empty_output><jupyter_text>So here, we had to make sure the first argument of our function was created to receive the instance it is being called from. After that we are free to add our own arguments as needed.<jupyter_code>p = Person()<jupyter_output><empty_output><jupyter_text>Let's see what `p` looks like when we print it:<jupyter_code>p<jupyter_output><empty_output><jupyter_text>And now let's call the `say_hello` method from the instance `p`:<jupyter_code>p.say_hello('Alex')<jupyter_output><empty_output><jupyter_text>You'll notice that we did not pass `p` as the first argument to `say_hello` - Python did that for us since we wrote `p.say_hello`. By convention, that `instance` argument I wrote above, is usually named `self`, for obvious reasons. But it is just a convention, although one you should stick to.<jupyter_code>class Person:
    def say_hello(self, name):
        return f'{self} says hello to {name}'
p = Person()
p.say_hello('Alex')<jupyter_output><empty_output><jupyter_text>So now let's turn our attention to instance attributes.  Python is dynamic, so instance attributes do not have to be defined at the time the class is created. In fact we rarely do so.Let's see how we can add an attribute to an instance after it's been created:<jupyter_code>p = Person()
p.name = 'Alex'<jupyter_output><empty_output><jupyter_text>That's it, `p` now has an attribute called `name`:<jupyter_code>p.name<jupyter_output><empty_output><jupyter_text>But this is specific to this instance, not other instances:<jupyter_code>p2 = Person()
p2.name<jupyter_output><empty_output><jupyter_text>So instance attributes are **specific** to the instance (hence the name).Of course we can define attributes by calling methods in our class - let's see an example of this:<jupyter_code>class Person:
    def set_name(self, name):
        self.name = name
        
    def get_name(self):
        return self.name
p = Person()<jupyter_output><empty_output><jupyter_text>At this point `p` does **not** have a `name` attribute (it hasn't been set yet!):<jupyter_code>p.get_name()<jupyter_output><empty_output><jupyter_text>But we can easily set it using the `set_name` method:<jupyter_code>p.set_name('Alex')
p.get_name()<jupyter_output><empty_output><jupyter_text>And of course the attribute is called `name` and is easily accessible directly as well:<jupyter_code>p.name<jupyter_output><empty_output><jupyter_text>This is what is called a *bare* attribute - it is not hidden by getter and setter methods like we would normally do in Java (remember we do not have private variables).You'll notice the issue we had - we would get an exception if we tried to access the attribute before it was actually created.For this reason, best practice is to create these instance attributes (even setting them to a default value or `None`) when the class instance is being created.The best place to do this is in the *initialization* phase of the class (remember that class instantiation has two phases - creation and initialization). To do this we use the special method `__init__`. This is going to be a functionm in our class, and will be bound to the instance when it is called (by that time the instance has already been created), so just like our `set_name` method, we'll need to allow for the instance to be received as the first argument:<jupyter_code>class Person:
    def __init__(self, name):
        self.name = name<jupyter_output><empty_output><jupyter_text>So the `__init__` method is basically doing the same thing as our `set_name` method - the difference is in how it is called.When we create an instance using `Person()`, Python looks for, and if available calls, the `__init__` method (that's why it's called a *special method*).In our case here, the first argument will receive the just created object, but we have one additional argument, `name`. So we need to pass that in when we create the instance:<jupyter_code>p = Person('name')<jupyter_output><empty_output><jupyter_text>The `__init__` method was actually called - let's see this:<jupyter_code>class Person:
    def __init__(self, name):
        print(f'__init__ called for object {self}')
        self.name = name
p = Person('Alex')<jupyter_output>__init__ called for object <__main__.Person object at 0x1081fe320><jupyter_text>And in fact, the memory address of `p` is:<jupyter_code>hex(id(p))<jupyter_output><empty_output><jupyter_text>which as you can see, is excactly the same object `self` was set to when `__init__` was called. And our instance `p` now has an attribute called `name`:<jupyter_code>p.name<jupyter_output><empty_output><jupyter_text>We can create another instance:<jupyter_code>p2 = Person('Eric')
hex(id(p2)), p2.name<jupyter_output><empty_output><jupyter_text>And that has not affected the `name` of `p` - since `name` is an instance attribute (it is specific to the instance):<jupyter_code>p.name<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.0770625566636446, 'fraction_numerical': 0.0016837197254241678, 'mean_word_length': 5.319148936170213, 'pattern_counts': {'":': 0, '<': 85, '<?xml version=': 0, '>': 85, 'https://': 0, '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': '21', 'dir': 'jupyter-structured-clean-dedup', 'id': '10534', 'license': 'MIT', 'n_tokens_mistral': 2203, 'n_tokens_neox': 2159, 'n_words': 1030, 'path': '13 - Classes.ipynb', 'repo_name': 'prabal5ghosh/python-primer', 'size': '7721'}