4

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()
Matthew
  • 53
  • 4
  • 1
    Does this answer your question? [How does the @property decorator work in Python?](https://stackoverflow.com/questions/17330160/how-does-the-property-decorator-work-in-python) – Sören Dec 29 '22 at 22:07
  • I just started learning OOP in Python and have not yet encountered property being used as a function, and the answers to that question honestly confuse me. I think I would really benefit from a step by step explanation of what is going on in the code I posted. – Matthew Dec 29 '22 at 22:17
  • 4
    @Matthew to *really* understand you have to understand descriptors, which is an intermediate/advanced topic in the language. The official [HOWTO](https://docs.python.org/3/howto/descriptor.html) is very thorough, and is one place to start. – juanpa.arrivillaga Dec 29 '22 at 23:52
  • It doesn't help that there's no obvious *reason* why all the components of the property need to have the same name. (The not-so-obvious reason is that `house.setter`, e.g, actually returns a *new* `property` instance based on `house`, rather than modifying the existing property in some way, and that will replace the original `property`.) – chepner Jan 05 '23 at 20:21
  • Basically, a `property` is just a collection of 0 or more functions that get called when and as required by the descriptor protocol. – chepner Jan 05 '23 at 20:22
  • (Yes, *zero* or more. You can define a property with no getters, setters, or deleters, and add/change them as you like. In doing so, you can even define a property with no getter, which makes it a write-only property.) – chepner Jan 05 '23 at 20:49

1 Answers1

2

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
chepner
  • 497,756
  • 71
  • 530
  • 681