Welcome to the third video of this series about
object-oriented programming and classes in Python! In this video you're going to learn how to
use special methods, also called dunder or magic methods, to implement different behaviors
when for example you add two numbers together, you print an object, etc etc. So, here I've got
the class we used also in the previous videos with just the "__init__" method. The "__init__"
method is actually part of the special methods and they are special methods invoked using a specific
syntax. We've already discussed "__init__" in a previous video so I'm not going to talk about
it specifically. So, a special method is used to describe what to do when using a special
syntax, but what is the special syntax? Well, let's see an example. So, when you add two numbers
together or two strings together Python recognises the "+" sign and uses one of the special methods
under the hood. So let's actually write two strings, so "string1" and "string2", something
like that and then we print "string1" + "string2" and of course we get something like "ab", so here
Python sees that you're trying to add two strings together using the "+" sign, here, and because
you have two string objects and the "+" sign, when you do this Python actually uses the "__add__"
special method of the string class, of course. So basically when you use the "+" sign to add two
strings together Python does something like this ,so "string1", "__add__", like that and then
"string2" and if you actually print it and run it you can see that "ab", "ab", they return the same
result. So this is just an example but Python does this when you use the "-" sign, the "len" function
to get the length, when you print something, when you compare objects, so using the "is greater
than", "is equal" etc etc. So having these methods available means that we can actually create our
operation for our classes, which is incredibly useful in my opinion. So, let's actually delete
the example here and see what we can do with our class, by the way there are a lot of special
methods you can use so it would be impossible to go through all of them here, so I'm going over
the ones that I think are more useful and you're more likely to need but you know that in the
docs you can find all of them, I leave all the important links in the description box down below.
So let's say we have two instances so, "person1" "John", 54, "London" and then we've got "person2",
let's call it "David", 24 and "Austin", something like that. Sso if we print them we get something
like this, so if we print the instances directly like this, "print" like that, we get something
like this, so we get "main person objects etc etc", so as you can see we just get that this is
a person object and this is the address basically. So, you need to know that when you print something
Python looks for the special method "__str__" and in case it doesn't find it, it uses a method
called "__repr__", let's actually add them to out class. So let's say that we want to add "__repr__"
like that, we need to pass "self "as usual, like that. So this method as stated in the docs
should be a representation of the object that we can actually use to create the same exact object
if you were to use it, so in this case something like this, so we want to return an f-string,
I'm going to use double quotes, so "Person", "self.name" and then "self.age" and then of course "self.city" like this and let's close that.
So, the "__repr__" method is called when you use the "repr" built-in function and when we
print an object and we haven't specified the "__str__" method, so let's try to actually print
that, let's do something like this, so print "repr(person1)" and this is actually the
same as doing something like "print", "person1. __repr__()" , like that, so this is the
same thing and if we call that, as you can see "Person('John')", so this is that and as you can
see this is the representation of what we actually used to create an object so this is the same as
this, so if you just copy and paste this you can create that object. And if we print without using
the "repr", so "person1", something like that you still get the same thing because the special
method "__repr__" is sort of fallback if you don't specify the "__str__" method so let's actually
write that here, so "def", "__str__", "self". Basically the "__str__" method is the one
used when you basically print an object of this class or when you use the "str"
function to convert the object to a string, so in this case I want to return an f-string
and I want just to add the "name" and then the "age ", like this, "from", I don't know, "self.city", I'm just making this up like that.
So now if we run this again, so this thing again, we get, like a,s you can see "John(54) from
London", as you can see now using the, when we print that, we get this, the "__str__" the result
of "__str__" and if we use that thing with "repr", you get this, cool. So as I mentioned this is
also used to convert the object to a string, so for example if I wanted to do something like
"person_string", "str(person2)", and then I can print "type(person_string)", so there you see that
you get a string and then "person_string" like that, as you can see you get "class", "string",
"David from Austin", so by using the "str" you can convert the "person1" object to a string and this
uses the "__str__" method like that, so the result is actually what you get, perfect! So now let's
say that, let's actually delete this, so now let's say that we want to compare two Person objects
with "greater than", "less than", etc and we want to compare them based on their "age", how can
we do that? So to do this there are other special methods, so let me copy a sort of table here.
So "__lt__", "less than", "less than or equal", "equal", "not equal", "greater than",
"greater than or equal to something", you can find those in the documentation as well so
I'm gonna delete them from here. So let's actually try to implement them, so let's try to implement
the "less than", so you've got "self" and "other". So what is "other"? So basically "self" is the
instance on the left of the sign and "other" is the one on the right because when you do
something like, let's say 5 "less than" 10, Python calls this method on this object, on the
5 integer object and the "other" here is 10, perfect! So, we need to return of course
"self.age" "less than" "other.age", so you get "True" if the "age" on the left
is actually less than the "age" on the right. In this case "person1" is 54 and "person2"
is 24, so if you do something like "print" "person1", I'm just printing them but you can
use something like "if person1 < person2", etc and also this is the same as doing something
like, "person1.__lt__(person2)" and if you were to have "person2" here and "person1" here this
would be inverted as well. So, if we run that "False" and "False". So basically Python sees
the "less than" sign here and uses the right method and it does this for all the other symbols
like the "greater than", etc etc, and even though we haven't written the "__gt__" method, which
stands for "greater than", if we do something like "print", "person1", "greater than", "person2"
and we run that, we get "True" and it still works because we have written the "__lt__" function
and Python automatically sort of creates the "__gt__" function because it just has to invert
the result, of course if you want to have the "__gt__" function that works differently, you can
specify another logic, by defining the "__gt__" and write something else in the body of the
method. Of course the "less than or equal to" and the "greater than or equal to" are different
signs so you need to specify the "__le__" and the "__ge__" methods otherwise you would get an
error about the fact that it's not implemented. I actually just want to mention one thing about
the equality, if you don't specify anything Python is going to use the "is" keyword to compare
the two instances. I actually talked about the IDs and the "is" keyword in another video
about mutable and immutable objects. So let's say that we have these two instances, so let's
delete those, so let's actually delete this and duplicate it, like that. So, they are equal but
they are not actually the same object in memory, which means that if we do something
like "print(person1== person2)and we print that you get "False" because they are
not the same object although they have the same attributes etc etc and we can change this behavior
using the "__eq__" method. So "def __eq__", "self" and "other" again, so we can do
something like "return self.name == other.name and self.age == other.age and self.city ==
other.city". So an object doesn't need to be the same object in memory to be equal to another
one, it just needs to have the same values, so now if we save that and you run this, you get
"True" because all of these here are the same as these even though these two objects are not the
same, they are equal because they have the same attributes. So now listen up because I'm going to
explain you one important thing, so if you don't set the "__eq__" method the object can be used
as, for example, dictionary key, because it's hashable by default and uses the "ID" to calculate
the hash, but if you set the "__eq__" method as we did here, then the object becomes unhashable. So
to check if an object is hashable we can use the "hash" function like this, so we can do something
like "print(hash(..." and then "...person1))" if we try to run that you get "TypeError: unhashable"
but if we didn't have the "__eq__" method here you get this, as you can see you get a number, which
means that the actual object is hashable, let's go back here perfect! So you've got the "__eq__"
method and you can also have the "__hash__" method but the "__hash__" method shouldn't be defined if
you don't Define the "__eq__" method and actually really really important most of the time you don't
want the object to be used as dictionary key, especially if your class defines mutable objects,
like in this case. So if you define the "__eq__" method and you don't define the "__hash__" method
automatically the object won't be usable in hashed collections like dictionaries because this
is unhashable because we actually added the "__eq__" method, but if you don't need to define
the "__eq__" method, so let's say that you don't need this, so you didn't write it or maybe the
"__hash__" method is inherited from a parent class for example, the object would be hashable and
that wouldn't be good. So to flag it as unhashable you can set this in the class definition, so you
can set something like, up here, like "__hash__= None", so now the object should be hashable
because we didn't define the "__eq__" method but still you get unhashable because we
added this one. If we were to remove that, as you can see you get something, so this is
actually working and if you want the object to be hashable, maybe because you want to use it as
dictionary key, which in this case doesn't make sense because you've got a mutable object so you
shouldn't do that but let's say that you want, we can actually uncomment this, you can actually
use the "__hash__" method but you need to keep in mind that objects that compare equal using this
the "__eq__" method need to have the same hash, so let's actually define "__hash__". "self", so in this case we are creating the
"__hash__" method and usually it's good to put all the attributes used in the "__eq__" method,
so here, self.name, self.age and self.city into a tuple, so put them into a tuple and then hash
that tuple. So in this way you are sure that the objects that result equal to the "__eq__" method
also have the same hash, so in this case you want to do something like "return hash", then the
tuple in here, "(self.name, self.age, self.city)" like that, so you hash this tuple and inside of
here you've got all the attributes here, as you can see "self.name", "self.age", "self.city".
By doing this you're saying to Python that the object "Person" is hashable and that it can be
used as dictionary key for example, but remember that this is not what you should do in this case,
because you've got mutable objects. By the way, just a side note, the hash returned by the "hash"
method is different between Python invocations, so as you can see now you've got "hash(person1),
you run it you get this number, you run it again you get this number, so they are not the same
and this is done for security purposes so if you need a hash that is consistent between Python
invocations, maybe because you want to store it in a database or something like that, you should use
a module like "hashlib" for example. Then, before we look at how to implement arithmetic operations
like adding to instances together, subtracting, etc etc, let's quickly have a look at the special
method "__bool__" which is basically a way to see if an object is "True" or "False". So to do that
we need to add this method here, this special method, which is "__bool__" of course and then
"self" and then here let's say that the "Person" object is "True" if we have a name, an age and
a city and if we don't have a name or a city or whatever the object is actually "False".
So "if self.name and self.age and self.city", "return True", "else", "return
False", like that. So let's say that we want to print, we want to see if the
"person1" is "True" or "False", we use the "bool" built-in function, "person1", "person2",
and let's say that here we don't have the name, if you run that you get "False" and "True",
of course if you had the name you would get "True" and "True" and this could be used like,
for example, something like "if person1", "print(True)", again you will get
"True", "True" and "True", perfect! Let's now talk about arithmetic operations and
they are quite useful and worth learning. By the way if you want to help me a little bit please
like the video and subscribe to the channel. It's really really simple yet really important for me
:) :) And also if you want to leave a comment down below I always appreciate them :) So there are
a lot of operations like addition, subtraction, multiplication, division, etc we're just going
to have a look at a few because otherwise the video would be too long, I'll leave all the
useful links in the description down below if you need them. So let's see what happens
if we add two "Person" instances together, so let's actually delete this, so "print(person1
+ person2)" like that and let's see what we get. We actually get "unsupported operand
type +" because we didn't specify that, so, to implement the method we need to
add the "__add__" method here, so, "def __add__", that that takes "self" and also "other",
where "self" is the object before the "+" sign and the "other" is the one after the "+" sign,
so in this case "person1" is "self", "person2" is "other". And let's say that we want to return
the the sum of their ages, it doesn't make sense but you'll definitely make things that make more
sense, so "return self.age + other.age", so now if you run that we get 48, perfect! In this case
you could even return a new "Person" object where maybe the name is the two names together, the age
could be the sum etc etc, so let's actually try that, so let's say "name" is equal to an f-string
with "self.name" and then "other.name" like that and maybe the age is not actually, so you've got
"self.age + other.age" like that and then the "city" will be "self.city" and "other.city", then
we can return a new instance so "Person(name, age, city)", like that, so now if you run this
you'll see that you'll get a new instance, so as you can see "David/David(48) from London",
let's actually change it and write something different so, like that, something like that,
so you get something like "John/David(58) from London/Austin" or something like that,
and we can assign it to a new variable because we're actually returning a new
instance, so we can do something like "new_person", "person1 + person2" and then we can
use that, and let's actually use the "IDs" to see, "person2" and then "new_person" like
that and let's see what we actually get, as you can see all of them are different
because we've actually created a completely new "Person" object by combining the other
two together and it doesn't make a lot of sense but it's just an example. And this is
actually used also when we do something like, this, so let's actually delete those,
this "__add__" here is used also when we do something like "person1 += person2",
which is equivalent to of course, you know that, this is the same as doing something like "...+
person2", like this and in this case it doesn't make sense because basically you would end up with
a completely new object in "person1", so you've got "person1" and "person2", you add the two
things together and then "person1" becomes a new object because you actually, you're still using
this with a new, and you return the new object, so you get a new object in "person1", so if you
do something like "print(id(person1)) and then you comment this out and you keep this, and then
you duplicate this, move that down here, if you run that you can see that you get two different
objects, so basically you've got a "person1", you've got a "person2", you add them together, you
create a new "Person" object and you assign it to the "person1" but it doesn't make sense because in
this case you can just update "person1" right? So to actually change the behavior of the "+=" sign
you can use a new special method and there is one for all the operations, you just need to add an
"i" before the name, so, you can do something like "def __iadd__(self, other)" and instead of
returning a completely new object we can just update the "person1" because we're
basically adding "person2" to "person1", I know that it doesn't make sense I keep saying
this because you might be thinking "what the hell is he doing?!", but of course your object is
going to make more sense, this is just an example. So "self.name" is equal to "f", let's actually
copy those from here, so "self.name" like that, like this, of course you need to do something like
"self.name", "self.age", because we are updating basically the "self", which is the instance on the
left of the sign and here we can just do something like "..+= other.age", like that "self.city",
"other.city", "name" etc etc and then we return "self", because we don't want to create a new
instance we just want to update the instance on the left so in this case the "ID" should
be the same. As you can see this is exactly the same because we didn't actually create a
new object, we added "person2" and we added that to "person1" and we just updated the
"person1", we didn't create a new instance, interesting! So if the video is already out
you should see it on the screen along with the playlist containing all the videos of this series
and don't forget to like this one if you enjoyed it :) And also subscribe to the channel for more
videos like this and I'll see you soon, bye! :) :)