Matthew
Matthew

Reputation: 71

How do getters and setters work in Python?

The following is a snippet of code from CS50P.

I don't understand how it works and cannot seem to find an adequate explanation.

Firstly, as soon as the Student constructor is called with user inputted arguments name and home, the init method is subsequently called with the same arguments. Now, it is unclear to me what exactly happens in the following two lines:

self.name = name
self.house = house

Essentially, from what I understand, since "name" in self.name matches with name(self, name) under @name.setter, self.name = name calls name(self, name) with the value of name as the argument. No return value is given, but instead, a new instance variable, namely _name is created, which is assigned to the same value of name (if the error check is passed). I do not understand why it is necessary to create this new variable with the underscore at the beginning in place of name. Also, I would like a more "under the hood" explanation of what "self.name" really does because I think my understanding is quite limited and might even be incorrect.

In addition, I was introduced to the idea of getters and setters out of nowhere, apart from being given the explanation that they allow user data to be validated when attribute values are set, whether they be set in the init function or outside of the class altogether. However, what do they really mean "under the hood" and what is the significance of the setter having a reference to the instance variable, but not the getter? Where do the names "property" and "name.setter" come from, and what about the "@" at the beginning? It's my first time seeing this syntax, so it is quite confusing and ilogical to me.

class Student:
    def __init__(self, name, house):
        self.name = name
        self.house = house

    def __str__(self):
        return f"{self.name} from {self.house}"

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if not name:
            raise ValueError("Invalid name")
        self._name = name

    @property
    def house(self):
        return self._house

    @house.setter
    def house(self, house):
        if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]:
            raise ValueError("Invalid house")
        self._house = house


def main():
    student = get_student()
    print(student)


def get_student():
    name = input("Name: ")
    house = input("House: ")
    return Student(name, house)


if __name__ == "__main__":
    main()

Upvotes: 4

Views: 450

Answers (1)

chepner
chepner

Reputation: 532003

The proposed duplicate, I think, provides much the same information as this answer. However, as you said you had trouble understanding it, perhaps part of your confusion stems from how the property type takes advantage of decorator syntax, so I'll try to start with an equivalent definition that doesn't use decorators at all. (For brevity, I'll omit the house property altogether to focus on just the name property, and skip __init__ and __str__, which don't change from the original definition.)

class Student:
    ...

    def _name_getter(self):
        return self._name

    def _name_setter(self, name):
        if not name:
            raise ValueError("Invalid name")
        self._name = name

    name = property(_name_getter, _name_setter)
    del _name_getter, _name_setter

Due to how the descriptor protocol works, accessing the class attribute name via an instance invokes the property's __get__ method, which calls the getter. Similarly, assignment to the class attribute via an instance invokes the property's __set__ method, which calls the setter. (The linked HOWTO also provides a pure Python definition of the property class so you can see exactly how the descriptor protocol applies here.)

In brief,

  • s.name becomes Student.__dict__['name'].__get__(s, Student), which calls _name_getter_(s).
  • s.name = "Bob" becomes Student.__dict__['name'].__set__(s, "Bob"), which calls _name_setter(s, "Bob").

Not that _name_getter and _name_setter, though defined like instance methods, are never actually used as instance methods. That's why I delete both names from the class namespace before the class gets created. They are just two regular functions that the property will call for us.


Now, we can make use of some helper methods defined by property to shift back to the original decorator-based definition. property.setter is an instance method that takes a setter function as its argument, and returns a new instance that uses all the functions of the original property, but replaces any existing setter with the new one. With that in mind, we can change our definition to

class Student:
    ...

    def _name_getter(self):
        return self._name

    def _name_setter(self, name):
        if not name:
            raise ValueError("Invalid name")
        self._name = name

    # Define a read-only property, one with only a getter
    name = property(_name_getter)
    # Replace that with a new property that also has a setter
    name = name.setter(_name_setter)
    del _name_getter, _name_setter

Both properties are assigned to the same attribute, name. If we used a different name for the second assignment, our class would have two different properties that operated on self._name: one that only has a getter, and one that has a getter and a setter.


property is applied to only one argument, so we can move back to using decorator syntax. Instead of defining _name_getter first, then applying property to it, we'll name the getter name to begin with and decorate it with property. (The name name will thus immediately have its original function value replaced with a property value that wraps the original function.)

class Student:
    ...

    @property
    def name(self):
        return self._name

    def _name_setter(self, name):
        if not name:
            raise ValueError("Invalid name")
        self._name = name

    name = name.setter(_name_setter)
    del _name_setter

Likewise, we can replace the explicit call of name.setter on the pre-defined _name_setter function with a decoration of a setter also named name. (Because of how decorators are actually implemented, the new function name will be defined after name.setter is evaluated to get the decorator itself, but before the decorator is called, so that we use the old property to define the new one before finally assigning the new property to the name name.)

class Student:
    ...

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if not name:
            raise ValueError("Invalid name")
        self._name = name

Upvotes: 2

Related Questions