DEV Community

Cover image for Writing Pythonic Code With Python Data Model
Noble-47
Noble-47

Posted on • Edited on

Writing Pythonic Code With Python Data Model

Special Methods

This apparent oddity is the tip of an iceberg that, when properly understood, is the key to everything we call Pythonic. The iceberg is called the Python data model, and it describes the API that you can use to make your own objects play well with the most idiomatic language features. - Luciano Ramalho (Fluent Python: clear, concise, and effective programming)

What's so special about the Python data model one may ask. Rather than giving a personal answer, why don't we do a little dive in and see what we can accomplish by understanding the data model. Data model simply speaks about how data is represented. In Python, data is represented by objects or to sound a bit technical, objects are Python's abstraction for data. The data model provides us with an API that allows our objects to play well with the 'under the hood' of Python programming.

In our little dive into Python data model, we are going to specifically focus on the special methods. Special methods are class functions with special names that are invoked by special syntax. Defining these special methods in our class definitions can give our class instances some really cool Python powers like iteration, operator overloading, working well with context managers (the 'with' keyword), proper string representation and formatting, and many more. To show you how you could implement these special functions into your classes, we will consider two examples of situations where using these special functions would make our codes clearer and more Pythonic.

The first example is a little bit outside-the-box solution I came up with for creating a simple game of Rock-Paper-Scissors in Python and the second is going to be a bit mathematical in nature but I'm going to walk you through each line of code

A Simple Game Of Rock Paper Scissors

Just in case you are not familiar with the Rock-Paper-Scissors game, it is originally a hand game usually played among two people that involves making signs of either Rock or paper or scissors. Knowing the whole history of the game doesn't really matter what is important is knowing how to determine the winner. In a conventional setting, a hand sign of rock would always win against scissors but will lose against paper, a hand sign of scissors would win against paper and lose to rock and obviously, paper would lose to scissors and win against rock. we can summarize this as shown below

Image of rock, paper, scissors game

For our Python emulation of this game, we will limit the number of players to just two, one player would be the computer and the other would be the user. Also, this is not a machine learning article or a write-up about computer vision, our users would still have to type in an option between rock, paper, and scissors on the terminal for our program to work.
Before we go into the actual coding, it's good that we take a step back and consider how we want our Python script to be. For my solution to this challenge, I will use the random module to enable the computer select a random option of either rock, paper, or scissors. To implement how our code evaluates the winner, I'm going to make the following assumptions:

I'm also going to take an OOP approach; our rock, paper, and scissors will be treated as objects and not string variables. Rather than creating three separate classes for each, I'll create only one that can represent any of them. This approach would also allow me to show you how special methods make life easier. Now to the fun aspect!

Class Definition

Naming our class RPS may sound a bit odd, but I found the name 'RPS' to be a good fit cause each letter comes from the initials, R for Rock, P for Paper, and S for Scissors. What's important to note here is that creating an instance of our class requires two arguments: pick and name. We already stated that the users of our script would have to type in their selected option on the terminal, instead of making our users type in 'Paper' (which could be so stressful for them) why don't we just allow our user to type in 'P' (or 'p') to select 'Paper', that's what the pick stands for. The name property is the actual name e.g 'Paper'. So now that we know what each parameters is for, we can now inspect our class by creating an instance

>>> p = RPS('P', 'Paper') # create an instance
>>> p.name
# return : Paper
>>> p.pick
# return : P
>>> print(p)
# return : <__main__.RPS object at 0x...>
Enter fullscreen mode Exit fullscreen mode

Our class instance was created and has the right attributes but notice what we get when we try to print the contents of the variable holding our class instance. Before getting into the technical details of how our class instance returns the odd-looking string, let's update our class definition by adding a single special function and see the difference.

Now let's create an instance and try printing our class instance again

>>> p = RPS('P', 'Paper')
>>> print(p)
# return : RPS(P, Paper)
Enter fullscreen mode Exit fullscreen mode

As we can see, by defining the 'repr' method we can achieve a better looking result. Let's make one more change to our class definition.

Now let's create an instance and test it again.

>>> p = RPS('P', 'Paper')
>>> p
# return : RPS(P, Paper)
>>> print(p)
# return : Paper
>>> str(p)
# return : 'Paper'
>>> repr(p)
# return : 'RPS(P, Paper)'
Enter fullscreen mode Exit fullscreen mode

To know what's going on here, we need to know a little about the print function. The print function converts all non-keyword arguments(like our p variable) to string using the built-in Python class str. If calling str() on our variable fails, python falls back on the built-in repr function. When str is called on our object, it looks for a __str__ method, if it finds none, it fails and then searches for a __repr__ method. Both the __str__ and the __repr__ methods are special methods used for string representation of our object. The __repr__ method gives the official string representation of our object while the __str__ method gives a friendly string representation of our object. I usually say that the __repr__ method is like talking to another developer and it usually shows how to call our class and the __str__ is like talking to a user of our program (like the player in this case), you would usually just want to return a simple string like "Paper" to show the user what he picked.

Although I stated the __repr__ and __str__ as the two special functions in our class definition, there's actually a third special method, and yes it is the most common one, the __init__ function. It is used for initializing our class and called by the __new__ special method just before returning our class instance. Did I just mention another special method we haven't defined? yes, I did. It may also interest you to know that Python automatically adds some other special methods to our class. You can check them out by calling the built-in function dir on our class instance like this

>>> dir(p)
# returns : ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'name', 'pick']
Enter fullscreen mode Exit fullscreen mode

Special functions or methods can be identified by the way they are named, they always begin with double underscores '' and end with double underscores '' Because of this special way of naming these methods, they are commonly called daunder methods (Double + UNDERscores = DUNDER). So if a method name begins with double underscores, it is most likely but not certainly a special method. Why not certainly? this is simply because Python does not stop us from defining our own methods using the dunder syntax. Alright back to our game script.

All that's left for us now is for us to let our script know how to determine a winner. As stated earlier, I will use comparison to evaluate a winner.

# comparison logic
Rock > Scissors
Scissors > Paper
Paper > Rock
Enter fullscreen mode Exit fullscreen mode

To implement this solution, I will add a dictionary and use the daunder greater_than method. The dictionary key would be the initials of Rock, Paper and Scissors. The value of each key would be the only other element that the key can defeat.

Notice the new lines of code, first the options dictionary and then the __gt__ method definition. With these new lines of code, let's see what new functionality our code now has.

# create a rock instance
>>> r = RPS('R', 'Rock')

# create a paper instance
>>> p = RPS('P', 'Paper')

# create a scissors instance
>>> s = RPS('S', 'Scissors')

>>> print(r,p,s)
# return : Rock Paper Scissors

>>> p > r # paper wins against rock
# return : True

>>> r > s # rock wins against scissors
# return : True

>>> s > p # scissors wins against paper
# return : True

>>> p < s # paper lose to scissors
# return : True

>>> p < r # paper lose to rock
# return : False

>>> p < s < r# paper lose to scissors which lose to rock
# return : True

>>> p >= r paper wins or tie to rock
# return : Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
TypeError: '>=' not supported between instances of 'RPS' and 'RPS'
Enter fullscreen mode Exit fullscreen mode

Just by adding to __gt__ special method, our class instances have gained magical powers (daunder methods are sometimes called magic methods and we can see why). By implementing the daunder gt method, our class instance now relates well with the > and < symbols but not the and symbols. The reason is that < is just the negation of >. The special method for < is __lt__(self, x) which can just be the negation of calling __gt__(self,x).

For the ≥ symbol, its special method is the __ge__(self, x) and it must be defined for our object to work well with the sign. But in this program, we can do without it.

Another missing piece would be to check if two separate instances of Paper are equal.

>>> p1 = RPS("P", "Paper")
>>> p2 = RPS("P", "Paper")
>>> p3 = p1
>>> p1 == p2
# return : False

>>> p1 == p3
# return : True

>>> id(p1)
# return : 140197926465008

>>> id(p2)
# return : 140197925989440

>>> id(p3)
# return : 140197926465008

>>> id(p1) == id(p3)
# return : True

>>> id(p2) == id(p1)
# return False
Enter fullscreen mode Exit fullscreen mode

The default operation of the equal comparison sign is to compare the id of the object. p1 and p2 are different class instances that happen to have the same attributes but their id differs and therefore are not equal. When we assign a variable to a class instance, we make that variable point to the address of the instance which is what we observe for p3 which has the same id as p1. We have the option of overriding how the equality comparison works on our object by defining and implementing our own __eq__(self, other) method. But for this script, I will compare two instances using their pick attribute. Now that we have defined our class and know how it works, we are now ready to see the full implementation of the Python script

Putting It All Together

Let me walk you through the code. We are already familiar with the RPS class definition. If you recall, our code is meant to allow the computer to select choices at random and that's what the random module is for. The random module makes available the choice function which allows the 'random' selection of an element from an iterable object e.g. a list in Python. The list in this case is the option_list. Because our class is made to work with uppercase letters for comparison, it is necessary that we always initialize our objects with uppercase for the pick attribute. This is why we first convert the user's input to upper case (line 34) with the .upper(). It is also possible that our user types in an unexpected character like 'Q' so we have to validate our user input by checking if the uppercase character is part of the valid options in option_list. The mapping dictionary allows us to quickly convert the user's input to a corresponding instance of RPS after being validated. The evaluate_winner function makes use of the comparison symbol to determine the winner. Because we want the code to run in a loop until a winner is found, we make use of a while loop and when a winner is found, the evaluate_winner function returns True which will then break the loop and exit the game.

Here is one of the various results of running the code

Image of running the python script

Our Python code runs as expected, although there could be a couple of improvements or new features to add. The most important thing is that we see how using special methods in our class definition gives our code a more Pythonic feel. Assuming we were to take a different approach such as using nested if statements, our evaluate_winner method would look something like this

def evaluate_winner(user_choice, comp_choice):
    # check if user choice is 'R'
    if user_choice == 'R':
        # check if comp_choice is 'R'
        if comp_choice == 'R':
            # it is a tie
            ...
        elif comp_choice == 'S':
            # user wins
            ...
        else:
          # computer wins
          ...
    if ... 
     # do the same for when user_choice is 'S' and then for
     # when user_choice is 'P'
Enter fullscreen mode Exit fullscreen mode

A problem with this approach other than the lengthy code is that if we desire to add a new element, diamond which can beat both rock and scissors but not paper (for an unknown reason), our if statements would begin to look really awkward. Whereas in our OOP approach, all we have to do is to modify the options dict like so

options = {"R" : ["S"], "P" : ["R"], "S" : ["P"], "D" : ["R", "S"]}
Enter fullscreen mode Exit fullscreen mode

and then we change the if statement in __gt__ to be

def __gt__(self,x):    
    if x.pick in self.options[self.pick]:
        return True
    else:
        return False
Enter fullscreen mode Exit fullscreen mode

we can make the statement shorter

def __gt__(self, x):
   return True if x.pick in self.options[self.pick] else False
Enter fullscreen mode Exit fullscreen mode

To conclude, here are some things you should note about using special methods:

  • You hardly (or never) call them directly yourself, let Python do the calling for you

  • When defining functions that use the dunder naming syntax, you should consider that Python could one day define such a function and give it a different meaning. This could break your code or make it behave in unexpected ways

  • You certainly don't have to implement every special method there is. Just a couple that you are really sure you need. Remember, simple is better than complex. If there's a simpler way you should use that instead

To learn more about the Python data model I recommend the official Python documentation.

I'll also recommend reading Fluent Python by Luciano Ramalho

This is the first part of the topic, in the next part, we are going to be dealing with operator overloading and making iterable objects

Hope you enjoyed this article!!!

Top comments (0)