Les properties en POO

Introduction

En Python, les properties constituent un mécanisme essentiel de la programmation orientée objet (POO) pour encapsuler les attributs d'une classe. Elles offrent un contrôle précis sur la manière dont les attributs sont accédés, modifiés ou supprimés, tout en maintenant une syntaxe propre et intuitive pour les utilisateurs de la classe. Les properties permettent de définir un comportement personnalisé lors de ces opérations, assurant ainsi l'intégrité et la cohérence des données.

Prenons l'exemple d'une classe représentant un rectangle. On pourrait vouloir garantir que la longueur et la largeur soient toujours des valeurs positives. Sans une property, il serait possible d'accéder directement aux attributs longueur et largeur et de leur attribuer des valeurs négatives, ce qui n'aurait aucun sens. Les properties évitent ce problème en interdisant l'accès direct aux attributs et en imposant une validation lors de leur modification.

En Python, une property est définie en utilisant la fonction intégrée property() ou, plus couramment, grâce au décorateur @property. Ces méthodes permettent d'associer un attribut à des fonctions spécifiques, appelées getter, setter et deleter. Ces fonctions contrôlent respectivement l'accès en lecture (getter), l'accès en écriture (setter) et la suppression (deleter) de l'attribut.

Voici un exemple simple de l'utilisation du décorateur @property dans une classe Circle pour contrôler l'accès à l'attribut radius (rayon) :


class Circle:
    def __init__(self, radius):
        self._radius = radius  # Prefix with _ to indicate it's a "protected" attribute

    @property
    def radius(self):
        # Getter method: returns the radius of the circle
        return self._radius

    @radius.setter
    def radius(self, value):
        # Setter method: ensures the radius is a positive value
        if value <= 0:
            raise ValueError("Radius must be a positive value.")
        self._radius = value

    @property
    def area(self):
        # Computed property: calculates the area of the circle
        return 3.14159 * self._radius ** 2

# Example usage:
circle = Circle(5)
print(f"Radius: {circle.radius}")  # Access using circle.radius
print(f"Area: {circle.area}") # Access using circle.area
circle.radius = 10
print(f"Radius after setting: {circle.radius}")
print(f"Area after setting: {circle.area}")

try:
    circle.radius = -1  # Attempt to set an invalid radius
except ValueError as e:
    print(e)  # Prints "Radius must be a positive value."

Dans cet exemple, radius est une property. L'utilisateur accède au rayon du cercle en utilisant circle.radius, comme s'il s'agissait d'un attribut public normal. Cependant, en réalité, la méthode getter est appelée en coulisses. De même, lorsqu'une nouvelle valeur est affectée à circle.radius, la méthode setter est invoquée, ce qui permet de valider la valeur avant de la stocker. L'attribut area est une autre property qui calcule la surface du cercle à la demande.

Les properties ne se limitent pas à la validation des données. Elles peuvent également être utilisées pour effectuer des calculs à la demande, comme illustré avec l'attribut area, ou pour déclencher d'autres actions lors de l'accès ou de la modification d'un attribut. Voici quelques cas d'utilisation courants des properties :

  • Validation : Assurer que les valeurs attribuées à un attribut respectent certaines contraintes.
  • Calcul à la demande : Calculer une valeur en fonction d'autres attributs, sans avoir à la stocker explicitement.
  • Encapsulation : Masquer la complexité interne d'une classe et fournir une interface simple et intuitive.
  • Effets de bord : Déclencher d'autres actions lors de l'accès ou de la modification d'un attribut (par exemple, mettre à jour un cache ou notifier d'autres objets).

L'utilisation judicieuse des properties contribue à améliorer la robustesse, la maintenabilité et la lisibilité du code orienté objet en Python. Elles permettent de créer des classes plus flexibles et plus faciles à utiliser. Cet article explorera plus en détail les différentes facettes des properties et leur implémentation pratique, en présentant des exemples concrets et des bonnes pratiques.

1. Comprendre le concept de Property en Python

En Python, une property est un attribut de classe qui permet d'implémenter des comportements spécifiques lors de l'accès, la modification ou la suppression d'un attribut. Elle offre un contrôle précis sur la façon dont les attributs d'une classe sont gérés, permettant ainsi de masquer la complexité interne et d'encapsuler la logique métier.

Considérons un exemple simple : une classe représentant un rectangle avec des attributs pour la largeur et la hauteur. On peut vouloir s'assurer que la largeur et la hauteur sont toujours positives. Une property peut être utilisée pour valider et contrôler ces valeurs.


class Rectangle:
    def __init__(self, width, height):
        # Initialize width and height with a protected naming convention
        self._width = width
        self._height = height

    def get_width(self):
        # Getter method for width
        return self._width

    def set_width(self, value):
        # Setter method for width with validation
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    def get_height(self):
        # Getter method for height
        return self._height

    def set_height(self, value):
        # Setter method for height with validation
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value

    # Creating a property for width and height
    width = property(get_width, set_width)
    height = property(get_height, set_height)

    def area(self):
        # Method to calculate the area of the rectangle
        return self._width * self._height

# Example usage
rect = Rectangle(5, 10)
print(f"Width: {rect.width}")
print(f"Height: {rect.height}")
print(f"Area: {rect.area()}")

rect.width = 7
print(f"New width: {rect.width}")

try:
    rect.height = -2
except ValueError as e:
    print(e)  # Output: Width must be positive

Dans cet exemple, width et height sont des properties. La fonction property() prend comme arguments les méthodes getter et setter (dans cet ordre). Lorsqu'on accède à rect.width, la méthode get_width() est appelée. Lorsqu'on assigne une valeur à rect.width, la méthode set_width() est appelée, permettant ainsi de valider la valeur avant de la stocker.

Une syntaxe plus concise et moderne pour définir des properties est l'utilisation des décorateurs. Cela rend le code plus lisible et plus facile à maintenir.


class Rectangle:
    def __init__(self, width, height):
        # Initialize width and height with a protected naming convention
        self._width = width
        self._height = height

    @property
    def width(self):
        # Getter method for width using the @property decorator
        return self._width

    @width.setter
    def width(self, value):
        # Setter method for width using the @width.setter decorator
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    @property
    def height(self):
        # Getter method for height using the @property decorator
        return self._height

    @height.setter
    def height(self, value):
        # Setter method for height using the @height.setter decorator
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value

    def area(self):
        # Method to calculate the area of the rectangle
        return self._width * self._height

L'utilisation du décorateur @property transforme la méthode width() en un getter pour la property width. Le décorateur @width.setter définit la méthode width() comme le setter pour cette même property. Cette syntaxe est plus intuitive et plus courante en Python.

En plus des getters et setters, une property peut également avoir un deleter, qui est une méthode appelée lorsqu'on supprime l'attribut en utilisant del rect.width. Pour ajouter un deleter, on utilise le décorateur @width.deleter.


class Rectangle:
    def __init__(self, width, height):
        # Initialize width and height with a protected naming convention
        self._width = width
        self._height = height

    @property
    def width(self):
        # Getter method for width using the @property decorator
        return self._width

    @width.setter
    def width(self, value):
        # Setter method for width using the @width.setter decorator
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    @width.deleter
    def width(self):
        # Deleter method for width using the @width.deleter decorator
        print("Deleting width")
        self._width = None

    @property
    def height(self):
        # Getter method for height using the @property decorator
        return self._height

    @height.setter
    def height(self, value):
        # Setter method for height using the @height.setter decorator
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value

    def area(self):
        # Method to calculate the area of the rectangle
        return self._width * self._height

# Example usage
rect = Rectangle(5, 10)
del rect.width  # Output: Deleting width
print(rect.width)  # Output: None

En résumé, les properties en Python sont un outil puissant pour encapsuler et contrôler l'accès aux attributs d'une classe. Elles permettent d'ajouter une logique métier à l'accès, à la modification et à la suppression des attributs, tout en conservant une interface propre et intuitive pour les utilisateurs de la classe.

Voici un récapitulatif des avantages des properties :

  • Encapsulation : Masquent l'implémentation interne des attributs.
  • Validation : Permettent de valider les données avant de les assigner.
  • Calculs dynamiques : Peuvent effectuer des calculs à la volée lors de l'accès à un attribut.
  • Flexibilité : Offrent la possibilité de modifier le comportement des attributs sans casser le code existant.

Les properties sont particulièrement utiles dans les cas suivants :

  1. Lorsque vous souhaitez valider les données avant de les stocker.
  2. Lorsque vous avez besoin de calculer une valeur dynamiquement en fonction d'autres attributs.
  3. Lorsque vous souhaitez masquer la complexité interne d'une classe.
  4. Lorsque vous devez contrôler l'accès à certains attributs.

1.1 Définition et motivation

En Python, une property est un objet qui permet de gérer l'accès à un attribut de classe de manière contrôlée. Elle fournit une interface élégante pour obtenir (getter), modifier (setter), et supprimer (deleter) la valeur d'un attribut, tout en permettant d'encapsuler une logique personnalisée autour de ces opérations.

La motivation principale derrière l'utilisation des properties réside dans l'encapsulation, un principe fondamental de la programmation orientée objet. L'encapsulation consiste à masquer l'état interne d'un objet et à fournir une interface publique (méthodes et properties) pour interagir avec lui. Les properties offrent un contrôle précis sur la façon dont les attributs sont consultés et modifiés, contribuant ainsi à maintenir l'intégrité des données et à prévenir les erreurs potentielles. Elles permettent de valider les données, de déclencher des actions supplémentaires lors de l'accès ou de la modification, et de masquer la complexité interne.

Considérons l'exemple d'une classe Circle représentant un cercle. Il est crucial de s'assurer que le rayon d'un cercle est toujours une valeur positive. Sans property, on pourrait simplement définir un attribut radius accessible directement, mais cela exposerait l'attribut à des modifications non contrôlées, compromettant ainsi l'intégrité de la classe.


class Circle:
    def __init__(self, radius):
        self.radius = radius  # Problem: no validation of the radius

# Creating a circle with a negative radius (which is nonsensical)
c = Circle(-5)
print(c.radius)  # Prints -5

En utilisant une property, on peut intercepter l'accès à l'attribut radius et effectuer des validations nécessaires pour garantir que le rayon reste une valeur valide.


class Circle:
    def __init__(self, radius):
        self._radius = radius  # Use a "private" variable to store the radius

    def get_radius(self):
        return self._radius

    def set_radius(self, radius):
        if radius <= 0:
            raise ValueError("Radius must be positive")
        self._radius = radius

    def delete_radius(self):
        del self._radius

    radius = property(get_radius, set_radius, delete_radius, "Radius of the circle")

# Creating a circle
c = Circle(5)
print(c.radius)

# Trying to set a negative radius
try:
    c.radius = -5
except ValueError as e:
    print(e)

# Setting a valid radius
c.radius = 10
print(c.radius)

# Deleting the radius (not commonly used, but possible)
del c.radius
try:
    print(c.radius)
except AttributeError as e:
    print(e)

Dans cet exemple, on définit une variable "privée" _radius pour stocker la valeur du rayon. La property radius utilise les méthodes get_radius (getter), set_radius (setter) et delete_radius (deleter) pour contrôler l'accès à cette variable. La méthode set_radius effectue une validation pour s'assurer que le rayon est positif. Si l'on tente de définir un rayon négatif, une exception ValueError est levée. Ceci permet de garantir l'intégrité des données et d'éviter les erreurs potentielles. La property permet de présenter _radius comme un attribut public (radius) tout en contrôlant son accès.

Il existe également une manière plus moderne et concise de définir des properties en utilisant des décorateurs:


class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, radius):
        if radius <= 0:
            raise ValueError("Radius must be positive")
        self._radius = radius

    @radius.deleter
    def radius(self):
        del self._radius

En résumé, les properties en Python offrent un mécanisme puissant pour encapsuler l'accès aux attributs d'une classe, permettant ainsi de contrôler leur comportement et de maintenir l'intégrité des données. Elles sont un outil essentiel pour écrire du code propre, robuste, flexible et maintenable en programmation orientée objet.

Voici quelques avantages clés de l'utilisation des properties:

  • Encapsulation : Masquent l'implémentation interne et protègent les données.
  • Validation : Permettent de valider les données avant de les affecter à un attribut.
  • Abstraction : Offrent une interface uniforme pour accéder et modifier les attributs, même si l'implémentation interne change.
  • Flexibilité : Permettent d'ajouter une logique personnalisée lors de l'accès, de la modification ou de la suppression d'un attribut.

1.2 Avantages de l'utilisation des properties

L'utilisation des properties en Python offre plusieurs avantages significatifs, contribuant à un code plus propre, plus maintenable et plus robuste. Explorons ces avantages en détail.

Encapsulation des attributs: Les properties permettent de contrôler l'accès aux attributs d'une classe. Au lieu d'accéder directement à instance.attribute, vous passez par instance.property, ce qui offre un niveau d'abstraction crucial. Cela signifie que vous pouvez modifier l'implémentation interne de la classe (par exemple, changer le nom d'un attribut interne) sans impacter le code client qui utilise la classe.


class Person:
    def __init__(self, name):
        self._name = name  # convention: _name is considered a "private" attribute

    def get_name(self):
        print("Fetching name")
        return self._name

    def set_name(self, new_name):
        print("Setting name")
        self._name = new_name

    name = property(get_name, set_name)

# Usage
person = Person("Alice")
print(person.name)  # Output: Fetching name \n Alice
person.name = "Bob" # Output: Setting name
print(person.name)  # Output: Fetching name \n Bob

Validation des données: Les properties permettent d'intégrer une logique de validation lors de la modification d'un attribut. Cela garantit que les valeurs attribuées à l'attribut respectent certaines contraintes, prévenant ainsi des erreurs et assurant un état cohérent de l'objet. Ceci est particulièrement utile pour maintenir l'intégrité des données.


class Account:
    def __init__(self, balance):
        self._balance = balance

    def get_balance(self):
        return self._balance

    def set_balance(self, amount):
        if amount < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = amount

    balance = property(get_balance, set_balance)

# Usage
account = Account(100)
print(account.balance) # Output: 100
try:
    account.balance = -50  # This will raise a ValueError
except ValueError as e:
    print(e) # Output: Balance cannot be negative
account.balance = 200
print(account.balance) # Output: 200

Implémentation de logique complexe: Les properties ne se limitent pas à la simple lecture et écriture d'attributs. Elles permettent d'exécuter une logique plus complexe lors de l'accès (getter) ou de la modification (setter) d'un attribut. Cela peut inclure des calculs, la mise à jour d'autres attributs, ou même des interactions avec des ressources externes. Elles offrent une manière élégante de cacher la complexité d'implémentation.


class Product:
    def __init__(self, price, discount):
        self._price = price
        self._discount = discount

    def get_discounted_price(self):
        return self._price * (1 - self._discount)

    def set_discount(self, new_discount):
        if not 0 <= new_discount <= 1:
            raise ValueError("Discount must be between 0 and 1")
        self._discount = new_discount

    discounted_price = property(fget=get_discounted_price) #read-only property
    discount = property(fget=lambda self: self._discount, fset=set_discount) #access to discount

# Usage
product = Product(100, 0.2)
print(product.discounted_price) # Output: 80.0
product.discount = 0.3
print(product.discounted_price) # Output: 70.0
try:
    product.discount = 1.5
except ValueError as e:
    print(e) # Output: Discount must be between 0 and 1

Simplification de l'API: Les properties permettent de présenter une API plus simple et intuitive aux utilisateurs de la classe. En utilisant la notation d'attribut (instance.property) au lieu d'appels de méthode (instance.get_property()), le code devient plus lisible et plus naturel. Cela améliore l'expérience de développement et réduit la complexité pour les consommateurs de la classe, tout en conservant la flexibilité interne.

En résumé, les properties sont un outil puissant en Python pour l'encapsulation, la validation des données, l'implémentation de logique complexe et la simplification de l'API. Elles contribuent à un code plus propre, plus maintenable et plus facile à utiliser, améliorant ainsi la qualité globale du logiciel. Elles permettent d'allier la simplicité d'accès aux attributs avec la puissance du contrôle offert par les méthodes.

2. Création de Properties avec la fonction `property()`

La fonction property() est une fonction intégrée de Python utilisée pour créer des propriétés (properties) dans une classe. Elle permet de gérer l'accès aux attributs d'une manière plus contrôlée. Elle prend jusqu'à quatre arguments optionnels : fget (getter), fset (setter), fdel (deleter) et doc (docstring).

Voici un exemple qui illustre l'utilisation de la fonction property() :


class Car:
    def __init__(self, brand):
        self._brand = brand  # Protected attribute

    def get_brand(self):
        print("Getting the brand")  # Getting message
        return self._brand

    def set_brand(self, new_brand):
        print("Setting the brand")  # Setting message
        self._brand = new_brand

    def del_brand(self):
        print("Deleting the brand")  # Deleting message
        del self._brand

    brand = property(get_brand, set_brand, del_brand, "Car brand")

# Example usage
my_car = Car("Renault")
print(my_car.brand)
my_car.brand = "Peugeot"
print(my_car.brand)
del my_car.brand

Dans cet exemple :

  • get_brand : est la méthode getter qui est appelée lorsqu'on accède à l'attribut brand.
  • set_brand : est la méthode setter qui est appelée lorsqu'on modifie la valeur de l'attribut brand.
  • del_brand : est la méthode deleter qui est appelée lorsqu'on supprime l'attribut brand.
  • "Car brand" : est la docstring de la property, fournissant une description de l'attribut.

Lorsqu'on accède à my_car.brand, la méthode get_brand est exécutée. Lorsqu'on assigne une valeur à my_car.brand, la méthode set_brand est exécutée. Et lorsqu'on utilise del my_car.brand, la méthode del_brand est exécutée.

Il est également possible de créer une property en lecture seule en ne fournissant que la méthode getter. Cela empêche la modification ou la suppression de l'attribut via la property.


class Circle:
    def __init__(self, diameter):
        self._diameter = diameter

    def get_radius(self):
        return self._diameter / 2

    radius = property(get_radius)

# Example usage
my_circle = Circle(10)
print(my_circle.radius)
# Attempting to modify (will raise an AttributeError)
# my_circle.radius = 6

Dans ce cas, tenter de modifier my_circle.radius lèvera une AttributeError car aucun setter n'a été défini pour la property radius.

La fonction property() offre une manière flexible de définir des getters, des setters et des deleters, permettant un contrôle précis sur l'accès et la modification des attributs d'une classe. Ceci contribue à améliorer l'encapsulation et la lisibilité du code, en particulier dans des classes complexes nécessitant une logique spécifique lors de l'accès aux attributs.

2.1 Syntaxe de la fonction `property()`

La fonction property() est un outil puissant en Python pour créer des properties, offrant un contrôle précis sur l'accès aux attributs d'une classe. Elle permet de définir des méthodes getter, setter et deleter pour gérer la lecture, l'écriture et la suppression d'attributs. La syntaxe générale de la fonction property() est la suivante :


property(fget=None, fset=None, fdel=None, doc=None)

Chaque argument a une signification spécifique :

  • fget (optionnel) : Une fonction sans argument qui est appelée pour obtenir la valeur de l'attribut (getter). Elle doit retourner la valeur de l'attribut.
  • fset (optionnel) : Une fonction avec un argument (la valeur à définir) qui est appelée pour définir la valeur de l'attribut (setter).
  • fdel (optionnel) : Une fonction sans argument qui est appelée pour supprimer l'attribut (deleter).
  • doc (optionnel) : Une chaîne de caractères qui sert de documentation (docstring) pour la property. Elle sera affichée par la fonction help().

La fonction property() renvoie un objet property. Cet objet est ensuite assigné à un attribut de la classe, transformant cet attribut en une property. Voici un exemple concret :


class Book:
    def __init__(self, title):
        self._title = title  # Protected attribute

    def get_title(self):
        # Getter method for the 'title' attribute
        print("Getting the title")
        return self._title

    def set_title(self, value):
        # Setter method for the 'title' attribute with validation
        print("Setting the title")
        if not isinstance(value, str):
            raise TypeError("Title must be a string.")
        self._title = value

    def del_title(self):
        # Deleter method for the 'title' attribute
        print("Deleting the title")
        del self._title

    title = property(get_title, set_title, del_title, "Book's Title")

# Example Usage
my_book = Book("The Little Prince")
print(my_book.title)  # Accessing the title using the getter
my_book.title = "Twenty Thousand Leagues Under the Sea"  # Setting the title using the setter
print(my_book.title)
del my_book.title  # Deleting the title using the deleter

try:
    my_book.title = 123  # Attempting to set an invalid title
except TypeError as e:
    print(e)

Dans cet exemple, title est une property qui utilise les méthodes get_title, set_title et del_title pour gérer l'accès et la modification de l'attribut _title. La chaîne de caractères "Book's Title" sert de docstring pour la property title, accessible via help(Book.title).

L'utilisation de property() offre un contrôle précis sur la manière dont les attributs d'une classe sont accédés et modifiés, permettant d'encapsuler la logique métier, d'appliquer des validations et d'assurer l'intégrité des données. Cela favorise une meilleure abstraction et une plus grande flexibilité dans la conception de classes.

Il est important de noter que les méthodes getter, setter et deleter sont optionnelles. Vous pouvez créer une property en lecture seule en fournissant uniquement le getter, ou une property en écriture seule en fournissant uniquement le setter.

2.2 Exemple d'implémentation avec `property()`

La fonction property() est une façon explicite de créer des properties en Python. Elle accepte jusqu'à quatre arguments :

  • fget : La méthode getter.
  • fset : La méthode setter.
  • fdel : La méthode deleter.
  • doc : La chaîne de documentation (docstring).

Elle retourne un objet property qui peut être assigné à un nom d'attribut dans une classe.

Considérons la classe Author avec un attribut privé _first_name. Nous allons créer une property first_name pour contrôler l'accès et la modification de cet attribut, incluant une validation des données.


class Author:
    def __init__(self, first_name):
        self._first_name = first_name

    def get_first_name(self):
        # Getter method for the 'first_name' property
        print("Getting the first name")
        return self._first_name

    def set_first_name(self, new_first_name):
        # Setter method for the 'first_name' property, with validation
        if not isinstance(new_first_name, str):
            raise TypeError("The first name must be a string.")
        if not new_first_name:
            raise ValueError("The first name cannot be empty.")
        print("Setting the first name")
        self._first_name = new_first_name

    def del_first_name(self):
        # Deleter method for the 'first_name' property
        print("Deleting the first name")
        del self._first_name

    first_name = property(get_first_name, set_first_name, del_first_name, "Author's first name.")

# Example usage
author = Author("Victor")
print(author.first_name)  # Accessing the property using the getter
author.first_name = "Jules"  # Setting the property using the setter
print(author.first_name)
del author.first_name  # Deleting the property using the deleter

Dans cet exemple :

  • get_first_name est la méthode getter.
  • set_first_name est la méthode setter, incluant une validation pour s'assurer que le prénom est une chaîne de caractères non vide.
  • del_first_name est la méthode deleter.
  • first_name = property(get_first_name, set_first_name, del_first_name, "Author's first name.") crée l'objet property et l'associe aux méthodes correspondantes. Le dernier argument est la chaîne de documentation.

Lorsqu'on accède à author.first_name, la méthode get_first_name est appelée. Lorsqu'on assigne une valeur à author.first_name, la méthode set_first_name est appelée (avec validation). Enfin, lorsqu'on utilise del author.first_name, la méthode del_first_name est exécutée. La chaîne de documentation est accessible via Author.first_name.__doc__.

L'utilisation de property() offre un contrôle précis sur l'accès et la modification des attributs d'une classe, tout en conservant une syntaxe propre et intuitive. La validation des données dans le setter assure l'intégrité des données de l'objet.

3. Utilisation des Décorateurs `@property`, `@nom.setter`, `@nom.deleter`

Les décorateurs @property, @nom.setter et @nom.deleter offrent une approche élégante et Pythonique pour contrôler l'accès et la modification des attributs d'une classe. Ils permettent d'encapsuler la logique d'accès, de modification et de suppression d'attributs, tout en conservant une syntaxe intuitive comme si l'on accédait directement à un attribut public.

Le décorateur @property transforme une méthode en une propriété. Cela signifie que vous pouvez accéder à la méthode comme s'il s'agissait d'un attribut, sans utiliser de parenthèses. C'est particulièrement utile pour calculer des valeurs à la demande, masquer la complexité interne, ou valider des données avant de les renvoyer.


class Rectangle:
    def __init__(self, length, width):
        self._length = length  # Use _length to indicate it's intended as a protected attribute
        self._width = width    # Use _width to indicate it's intended as a protected attribute

    @property
    def area(self):
        # Calculates and returns the area of the rectangle.
        return self._length * self._width

# Example usage
my_rectangle = Rectangle(10, 5)
print(my_rectangle.area)  # Accessing area like an attribute (output: 50)

Dans cet exemple, area est une propriété. Lorsqu'on accède à my_rectangle.area, la méthode area() est exécutée en coulisses pour calculer et retourner la valeur de l'aire. L'utilisateur n'a pas besoin d'appeler une méthode directement.

Le décorateur @nom.setter permet de définir une méthode qui sera appelée lorsqu'on essaiera de modifier la valeur de la propriété nom. C'est essentiel pour implémenter un contrôle d'accès en écriture. Il permet d'ajouter de la validation, de normaliser les données entrantes, ou de déclencher d'autres actions lors de la modification d'un attribut.


class Person:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        # Returns the name of the person.
        return self._name

    @name.setter
    def name(self, new_name):
        # Validates the new name before setting it.
        if not isinstance(new_name, str):
            raise TypeError("Name must be a string.")
        if not new_name:
            raise ValueError("Name cannot be empty.")
        self._name = new_name.strip()  # Remove leading/trailing whitespace

# Example usage
person = Person("  John Doe  ")
print(person.name)  # Output:   John Doe

person.name = "Jane Smith"
print(person.name)  # Output: Jane Smith

try:
    person.name = 123  # Raises TypeError
except TypeError as e:
    print(e)

try:
    person.name = ""  # Raises ValueError
except ValueError as e:
    print(e)

Ici, le setter pour la propriété name effectue une validation : il vérifie que le nouveau nom est une chaîne de caractères non vide avant de l'affecter à l'attribut _name. Il supprime également les espaces superflus au début et à la fin du nom.

Le décorateur @nom.deleter permet de définir une méthode qui sera appelée lorsqu'on essaiera de supprimer la propriété nom à l'aide de l'instruction del. Il est utile pour empêcher la suppression accidentelle d'un attribut important, libérer des ressources associées, ou effectuer d'autres actions de nettoyage.


class Configuration:
    def __init__(self, filename):
        self._filename = filename

    @property
    def filename(self):
        # Returns the filename.
        return self._filename

    @filename.setter
    def filename(self, new_filename):
        # Sets the filename.
        self._filename = new_filename

    @filename.deleter
    def filename(self):
        # Prevents deletion of the filename attribute.
        print("Deleting the filename is not allowed.")
        # In a real-world scenario, you might perform cleanup actions here,
        # such as closing the configuration file or saving changes.

# Example usage
config = Configuration("app.conf")
print(config.filename)

del config.filename  # Calls the deleter method

Dans cet exemple, le deleter pour la propriété filename empêche la suppression directe de l'attribut et affiche un message. On pourrait également inclure une logique pour sauvegarder la configuration avant d'empêcher la suppression du nom de fichier.

L'utilisation combinée de @property, @nom.setter et @nom.deleter permet un contrôle précis sur la manière dont les attributs d'une classe sont accédés, modifiés et supprimés, contribuant ainsi à une meilleure encapsulation et à une conception plus robuste et maintenable. Ceci est un aspect clé de la programmation orientée objet en Python.

Voici une liste des avantages clés de l'utilisation des properties :

  • Encapsulation : Masque la complexité interne de la classe et protège les attributs.
  • Contrôle d'accès : Permet de contrôler comment les attributs sont lus, modifiés et supprimés.
  • Validation des données : Valide les données avant qu'elles ne soient affectées aux attributs.
  • Calcul à la demande : Permet de calculer des valeurs à la demande, au lieu de les stocker directement.
  • Flexibilité : Permet de modifier l'implémentation interne d'une classe sans affecter le code qui l'utilise.

3.1 Syntaxe des décorateurs

Le décorateur @property est utilisé pour définir la méthode getter d'une property. Il transforme une méthode en un attribut accessible en lecture seule. Cela permet d'accéder à la valeur de l'attribut d'une manière plus intuitive et propre. Voici un exemple:


class Thermostat:
    def __init__(self, temperature):
        self._temperature = temperature  # Private attribute to store temperature

    @property
    def temperature(self):
        # Returns the temperature
        return self._temperature

Dans cet exemple, le décorateur @property est appliqué à la méthode temperature. Cela signifie que vous pouvez accéder à la température comme un attribut, en utilisant thermostat.temperature, au lieu d'appeler une méthode comme thermostat.temperature(). L'attribut _temperature est conventionnellement préfixé d'un underscore, indiquant qu'il s'agit d'un attribut interne qui ne devrait pas être accédé directement en dehors de la classe.

Pour définir la méthode setter, qui permet de modifier la valeur de l'attribut, on utilise le décorateur @nom.setter, où nom est le nom de la property. Ce décorateur permet de contrôler et de valider la nouvelle valeur avant de la définir sur l'attribut. Voici comment ajouter un setter à l'exemple précédent:


class Thermostat:
    def __init__(self, temperature):
        self._temperature = temperature

    @property
    def temperature(self):
        return self._temperature

    @temperature.setter
    def temperature(self, new_temperature):
        # Check if the new temperature is valid
        if new_temperature < -273.15:
            raise ValueError("Temperature cannot be below absolute zero.")
        self._temperature = new_temperature

Dans ce cas, @temperature.setter est utilisé pour la méthode temperature. Maintenant, vous pouvez modifier la température en utilisant une simple affectation, par exemple thermostat.temperature = 25. Le setter effectue une validation pour s'assurer que la nouvelle température est valide (supérieure au zéro absolu) avant de mettre à jour l'attribut privé _temperature. Si la validation échoue, une exception ValueError est levée.

Enfin, le décorateur @nom.deleter est utilisé pour définir la méthode deleter. Il permet de contrôler ce qui se passe lorsqu'on tente de supprimer l'attribut. Voici un exemple d'ajout d'un deleter:


class Thermostat:
    def __init__(self, temperature):
        self._temperature = temperature

    @property
    def temperature(self):
        return self._temperature

    @temperature.setter
    def temperature(self, new_temperature):
        if new_temperature < -273.15:
            raise ValueError("Temperature cannot be below absolute zero.")
        self._temperature = new_temperature

    @temperature.deleter
    def temperature(self):
        # Reset temperature to default value
        self._temperature = 20
        print("Temperature reset to default value.")

Avec le décorateur @temperature.deleter, lorsque l'instruction del thermostat.temperature est exécutée, la méthode temperature décorée avec @temperature.deleter est appelée. Dans cet exemple, la température est réinitialisée à une valeur par défaut (20), et un message est affiché pour indiquer que l'attribut a été "supprimé" (en réalité, il est réinitialisé). Il est important de noter que del ne supprime pas nécessairement l'attribut de l'objet, mais appelle simplement le deleter.

L'utilisation de ces décorateurs offre plusieurs avantages:

  • Encapsulation : Ils permettent de masquer l'implémentation interne de la classe, protégeant ainsi l'état de l'objet.
  • Contrôle d'accès : Ils fournissent un contrôle précis sur la façon dont les attributs sont lus, modifiés et supprimés.
  • Validation : Ils permettent de valider les données entrantes, garantissant ainsi la cohérence de l'état de l'objet.
  • Lisibilité : Ils rendent le code plus propre et plus facile à comprendre, en utilisant une syntaxe intuitive pour accéder aux attributs.

Les décorateurs @property, @nom.setter et @nom.deleter sont des outils puissants pour l'encapsulation, la gestion des états d'objets et l'écriture de code Python plus propre et maintenable.

3.2 Avantages des décorateurs par rapport à `property()`

L'utilisation des décorateurs @property, @nom.setter et @nom.deleter offre une approche plus élégante et intuitive comparée à l'utilisation directe de la fonction property(). Ces décorateurs permettent de définir les getters, setters, et deleters directement à l'intérieur de la définition de la classe, ce qui améliore significativement la lisibilité et la maintenabilité du code.

Prenons l'exemple d'une classe Rectangle qui représente un rectangle avec des dimensions variables. Nous souhaitons contrôler l'accès et la modification de la largeur de ce rectangle tout en validant les nouvelles valeurs.


class Rectangle:
    def __init__(self, width):
        self._width = width

    @property
    def width(self):
        # Getter method to get the width of the rectangle
        return self._width

    @width.setter
    def width(self, new_width):
        # Setter method to set the width with validation
        if new_width <= 0:
            raise ValueError("Width must be positive")
        self._width = new_width

    @width.deleter
    def width(self):
        # Deleter method to delete the width attribute
        print("Deleting the width!")
        del self._width

Dans cet exemple, @property définit le getter pour l'attribut width. @width.setter définit le setter, qui inclut une validation pour s'assurer que la largeur est toujours une valeur positive. Enfin, @width.deleter spécifie le comportement à adopter lors de la suppression de l'attribut width.

Voici un exemple d'utilisation de cette classe :


my_rectangle = Rectangle(10)
print(my_rectangle.width)  # Access via the getter

my_rectangle.width = 20  # Modification via the setter
print(my_rectangle.width)

try:
    my_rectangle.width = -5  # Invalid modification attempt
except ValueError as e:
    print(e)

del my_rectangle.width  # Deletion via the deleter

# Attempt to access after deletion (will generate an error because _width no longer exists)
# print(my_rectangle.width)

Comparativement à la méthode property(), les décorateurs offrent une syntaxe plus concise et intuitive. Le code est plus lisible car les getters, setters et deleters sont définis directement au-dessus des fonctions correspondantes. La syntaxe des décorateurs est donc généralement préférée pour sa clarté et sa maintenabilité.

Voici les avantages spécifiques des décorateurs par rapport à l'utilisation de property():

  • Lisibilité accrue: Le code est plus facile à comprendre car la relation entre l'attribut et ses méthodes d'accès est explicite.
  • Syntaxe concise: Les décorateurs réduisent la quantité de code nécessaire pour définir une property.
  • Organisation du code: Les getters, setters et deleters sont regroupés logiquement dans la définition de la classe.

En résumé, l'utilisation des décorateurs @property, @nom.setter, et @nom.deleter est la méthode privilégiée en Python pour définir des properties. Ils offrent une syntaxe claire, améliorent la lisibilité du code, et permettent un contrôle précis sur l'accès et la modification des attributs d'une classe.

3.3 Exemple d'implémentation avec les décorateurs

Reprenons l'exemple de la classe Personne. Avec les décorateurs, le getter, le setter et le deleter de l'attribut nom seraient définis directement au sein de la classe, les uns après les autres, ce qui rend le code plus facile à lire et à comprendre.


class Personne:
    def __init__(self, nom):
        self._nom = nom

    @property
    def nom(self):
        # Getter for the 'nom' attribute
        return self._nom

    @nom.setter
    def nom(self, nouveau_nom):
        # Setter for the 'nom' attribute, with validation
        if not isinstance(nouveau_nom, str):
            raise TypeError("Le nom doit être une chaîne de caractères.")
        self._nom = nouveau_nom

    @nom.deleter
    def nom(self):
        # Deleter for the 'nom' attribute
        del self._nom

# Example usage
personne = Personne("Alice")
print(personne.nom)

personne.nom = "Bob"
print(personne.nom)

del personne.nom

try:
    print(personne.nom)
except AttributeError:
    print("L'attribut 'nom' a été supprimé.")

Dans cet exemple, @property transforme la méthode nom() en un getter, permettant d'accéder à self._nom comme s'il s'agissait d'un attribut. @nom.setter définit une méthode pour modifier la valeur de self._nom, incluant ici une validation. Enfin, @nom.deleter permet de supprimer l'attribut self._nom. L'avantage majeur est la centralisation de la logique d'accès et de modification directement dans la définition de la classe, améliorant la lisibilité et la maintenabilité du code.

Considérons un autre exemple avec une classe Article et un attribut prix. On pourrait vouloir s'assurer que le prix ne soit jamais négatif.


class Article:
    def __init__(self, prix):
        self._prix = prix

    @property
    def prix(self):
        # Getter for the 'prix' attribute
        return self._prix

    @prix.setter
    def prix(self, nouveau_prix):
        # Setter for the 'prix' attribute, with validation
        if not isinstance(nouveau_prix, (int, float)):
            raise TypeError("Le prix doit être un nombre.")
        if nouveau_prix < 0:
            raise ValueError("Le prix ne peut pas être négatif.")
        self._prix = nouveau_prix

    @prix.deleter
    def prix(self):
        # Deleter for the 'prix' attribute
        del self._prix

# Example usage
article = Article(50.0)
print(article.prix)

article.prix = 75.0
print(article.prix)

try:
    article.prix = -10
except ValueError as e:
    print(e)

Cet exemple illustre comment les décorateurs @property, @nom.setter et @nom.deleter offrent une manière élégante et concise de contrôler l'accès et la modification des attributs d'une classe, tout en permettant d'intégrer des validations et des comportements spécifiques.

En résumé, l'utilisation des propriétés avec les décorateurs offre les avantages suivants:

  • Encapsulation : Protège les attributs internes de la classe.
  • Validation : Permet de valider les données avant de les affecter à un attribut.
  • Readability : Améliore la lisibilité du code en centralisant la logique d'accès aux attributs.
  • Maintainability : Facilite la maintenance du code en regroupant la logique des getters, setters et deleters.

Il est important de noter que le nom de l'attribut "privé" (par convention préfixé par un underscore, comme _nom ou _prix) est une convention de nommage et n'empêche pas l'accès direct à l'attribut depuis l'extérieur de la classe. Les propriétés permettent de contrôler cet accès.

De plus, l'utilisation de del dans le deleter n'est pas obligatoire. On pourrait choisir de simplement définir une action à effectuer lors de la suppression "logique" de l'attribut, sans forcément le supprimer de la mémoire. Par exemple, on pourrait définir une valeur par défaut ou lever une exception si l'attribut est accédé après sa "suppression".


class Produit:
    def __init__(self, nom, prix):
        self._nom = nom
        self._prix = prix
        self._est_supprime = False  # Indicateur de suppression logique

    @property
    def prix(self):
        if self._est_supprime:
            raise AttributeError("Le prix a été supprimé.")
        return self._prix

    @prix.setter
    def prix(self, nouveau_prix):
        if self._est_supprime:
            raise AttributeError("Le prix a été supprimé et ne peut être modifié.")
        if not isinstance(nouveau_prix, (int, float)):
            raise TypeError("Le prix doit être un nombre.")
        if nouveau_prix < 0:
            raise ValueError("Le prix ne peut pas être négatif.")
        self._prix = nouveau_prix

    @prix.deleter
    def prix(self):
        print("Suppression logique du prix.")
        self._est_supprime = True

# Example
produit = Produit("Ordinateur", 1200)
print(produit.prix)

del produit.prix
try:
    print(produit.prix)
except AttributeError as e:
    print(e)

Dans cet exemple, l'attribut _prix n'est pas réellement supprimé de la mémoire avec del. Au lieu de cela, un indicateur _est_supprime est mis à True, et le getter et le setter lèvent une exception si l'attribut est accédé après sa "suppression". Cela permet un contrôle plus fin du cycle de vie de l'attribut.

4. Validation des données avec les Properties

Les properties en Python offrent bien plus qu'un simple contrôle d'accès aux attributs. Elles permettent d'implémenter une validation des données robuste avant même que les valeurs ne soient affectées aux attributs. Cette approche garantit que les attributs d'un objet conservent uniquement des valeurs valides, en accord avec les règles métier de votre application.

Prenons l'exemple concret d'une classe représentant un capteur. Ce capteur possède une plage de valeurs acceptables, et il est impératif de s'assurer que la valeur du capteur reste toujours à l'intérieur de cette plage.


class Sensor:
    def __init__(self, sensor_id, min_value, max_value):
        # Initializes the sensor with a sensor ID, minimum value, and maximum value.
        self._sensor_id = sensor_id
        self._value = None  # Initial value
        self._min_value = min_value
        self._max_value = max_value

    @property
    def value(self):
        # Returns the current value of the sensor.
        return self._value

    @value.setter
    def value(self, new_value):
        # Validates the new value and sets it if it's within the acceptable range.
        if not self._min_value <= new_value <= self._max_value:
            raise ValueError(f"Value must be between {self._min_value} and {self._max_value}")
        self._value = new_value

    @property
    def sensor_id(self):
        # Returns the sensor ID (read-only).
        return self._sensor_id

Dans cet exemple, la property value intègre un setter qui effectue la validation de la nouvelle valeur. Si la valeur proposée se situe en dehors de la plage autorisée, définie par _min_value et _max_value, une exception ValueError est levée. Cela empêche l'affectation d'une valeur non valide à l'attribut _value, assurant ainsi l'intégrité des données.

Voici un exemple d'utilisation de cette classe :


# Create a sensor with a valid range
my_sensor = Sensor("temp_sensor", 10, 30)

# Set a valid value
my_sensor.value = 25
print(f"Sensor value: {my_sensor.value}")

# Attempt to set an invalid value
try:
    my_sensor.value = 5  # Value outside the valid range
except ValueError as e:
    print(f"Error: {e}")

# The sensor_id cannot be modified
print(f"Sensor ID: {my_sensor.sensor_id}")

Ce code démontre de manière claire comment les properties permettent de valider les données en entrée, garantissant ainsi l'intégrité des données au sein de l'objet. De plus, l'absence de setter pour sensor_id signifie que sa valeur ne peut pas être modifiée après l'initialisation, ce qui rend cet attribut en lecture seule. Cette approche offre une protection supplémentaire contre les modifications non intentionnelles.

En résumé, la validation des données via les properties est une technique puissante pour créer des classes Python robustes et fiables. Elle contribue significativement à améliorer la qualité de votre code et à faciliter la détection des erreurs potentielles, assurant ainsi un comportement prévisible et stable de vos objets.

  • value.setter : Permet de définir une méthode pour modifier la valeur de l'attribut, incluant la logique de validation.
  • ValueError : Exception levée lorsqu'une valeur non valide est détectée.

4.1 Implémentation de la validation dans le Setter

Le setter d'une property représente un emplacement stratégique pour implémenter la validation des données. Avant d'assigner une nouvelle valeur à l'attribut sous-jacent, il est possible d'effectuer des vérifications pour s'assurer que cette valeur respecte les critères de validité définis. Cette approche permet de maintenir l'intégrité des données de l'objet et d'éviter des erreurs inattendues qui pourraient survenir ultérieurement dans le programme.

La validation peut porter sur plusieurs aspects tels que le type de données, la plage de valeurs autorisées, la longueur d'une chaîne de caractères, ou toute autre contrainte spécifique à l'attribut. Lorsqu'une validation échoue, il est recommandé de lever une exception, comme ValueError ou TypeError, afin de signaler que la valeur fournie est incorrecte.

Considérons l'exemple d'une classe Point qui représente un point dans un espace 2D. Nous allons valider que les coordonnées x et y sont bien des nombres et qu'elles se situent dans une plage de valeurs prédéfinie.


class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    @property
    def x(self):
        return self._x

    @x.setter
    def x(self, value):
        # Validate the type of the value
        if not isinstance(value, (int, float)):
            raise TypeError("x must be a number")
        # Validate the range of the value
        if not -100 <= value <= 100:
            raise ValueError("x must be between -100 and 100")
        self._x = value

    @property
    def y(self):
        return self._y

    @y.setter
    def y(self, value):
        # Validate the type of the value
        if not isinstance(value, (int, float)):
            raise TypeError("y must be a number")
        # Validate the range of the value
        if not -100 <= value <= 100:
            raise ValueError("y must be between -100 and 100")
        self._y = value

# Example usage
try:
    point = Point(10, 20)
    print(f"Point coordinates: ({point.x}, {point.y})")

    point.x = 50
    print(f"Updated x coordinate: {point.x}")

    point.y = 150  # Raises ValueError
except ValueError as e:
    print(f"ValueError: {e}")
except TypeError as e:
    print(f"TypeError: {e}")

Dans cet exemple, les setters de x et y effectuent deux vérifications : ils s'assurent que la valeur est un nombre (de type int ou float) et qu'elle est comprise entre -100 et 100. Si une de ces validations échoue, une exception est levée. Ainsi, l'objet Point contient toujours des données valides, ce qui contribue à la robustesse du code.

En intégrant la validation directement dans le setter de la property, la logique de validation est centralisée, garantissant que toute tentative de modification de l'attribut est soumise à ces règles. Cela simplifie la maintenance du code et réduit le risque d'introduction d'erreurs. De plus, cette approche offre une abstraction claire, masquant la complexité de la validation aux utilisateurs de la classe et leur fournissant une interface plus propre et plus facile à utiliser.

Voici quelques avantages clés de l'implémentation de la validation dans les setters :

  • Intégrité des données : Garantit que l'objet contient toujours des données valides et cohérentes.
  • Centralisation de la logique : La logique de validation est regroupée au même endroit, ce qui facilite sa maintenance et sa modification.
  • Réduction des erreurs : Diminue le risque d'introduire des erreurs dues à des données incorrectes.
  • Abstraction : Masque la complexité de la validation aux utilisateurs de la classe.

4.2 Gestion des erreurs et exceptions

La gestion des erreurs et des exceptions est un aspect essentiel de la programmation robuste en Python. La structure try...except est l'outil principal pour gérer les exceptions. Le code susceptible de lever une exception est placé à l'intérieur du bloc try. Si une exception se produit, l'exécution du bloc try est interrompue, et le contrôle est transféré au bloc except correspondant.

Voici un exemple qui démontre comment gérer les exceptions lors de l'affectation d'une valeur non valide à une property:


class Address:
    def __init__(self, number, street):
        self._number = number
        self._street = street

    @property
    def number(self):
        return self._number

    @number.setter
    def number(self, value):
        try:
            value = int(value) # Convert to int
            if value <= 0:
                raise ValueError("Street number must be a positive integer.")
            self._number = value
        except ValueError as e:
            print(f"Validation error: {e}") # Print the error message
        except TypeError:
            print("Error: Street number must be a number.") # Print the error message

    @property
    def street(self):
        return self._street

    @street.setter
    def street(self, value):
        if not isinstance(value, str):
            raise TypeError("Street name must be a string.")
        if not value: # Check if the string is empty
            raise ValueError("Street name cannot be empty.")
        self._street = value

Dans cet exemple, la property number valide que la valeur affectée est un entier positif. Si ce n'est pas le cas, une exception ValueError ou TypeError est levée. Le bloc except capture ces exceptions, affiche un message d'erreur approprié, et empêche l'affectation de la valeur invalide. De même, la property street vérifie que la valeur est bien une chaîne de caractères non vide.

Utilisation :


# Create an Address object
my_address = Address(10, "Avenue des Champs-Élysées")

# Try to set an invalid value for 'number'
my_address.number = -5 # This will trigger the ValueError

# Try to set an invalid value for 'number'
my_address.number = "abc" # This will trigger the ValueError

# Try to set a valid value for 'number'
my_address.number = 20
print(my_address.number)

# Try to set an invalid value for 'street'
try:
    my_address.street = 123  # This will raise a TypeError
except TypeError as e:
    print(f"Error: {e}")

try:
    my_address.street = ""  # This will raise a ValueError
except ValueError as e:
    print(f"Error: {e}")

Plusieurs blocs except peuvent être utilisés pour gérer différents types d'exceptions. Cela permet d'adapter la gestion des erreurs en fonction du type d'erreur rencontré, offrant ainsi une gestion plus granulaire et précise. Une bonne gestion des exceptions contribue à rendre le code plus robuste et plus facile à maintenir. Il est également possible d'utiliser un bloc finally qui sera toujours exécuté, que l'exception soit capturée ou non. Ceci est utile pour effectuer un nettoyage (libérer des ressources par exemple).

Voici quelques bonnes pratiques pour la validation des données avec les properties et la gestion des exceptions :

  • Validation précoce : Validez les données dès que possible pour éviter de propager des états invalides dans votre application.
  • Messages d'erreur clairs : Fournissez des messages d'erreur précis et informatifs pour faciliter le débogage.
  • Gestion spécifique des exceptions : Utilisez des blocs except spécifiques pour chaque type d'exception afin de gérer les erreurs de manière appropriée.
  • Nettoyage des ressources : Utilisez un bloc finally pour garantir que les ressources sont libérées, même en cas d'exception.

En conclusion, l'utilisation des blocs try...except est essentielle pour gérer les erreurs de validation des données dans les properties. Cela permet de garantir l'intégrité des données et de fournir un retour d'information clair à l'utilisateur en cas de problème. La gestion des exceptions doit être envisagée dès la conception de la classe pour anticiper les erreurs potentielles et les gérer de manière appropriée. L'utilisation judicieuse des properties, combinée à une gestion rigoureuse des exceptions, est un élément clé de la programmation orientée objet en Python.

5. Properties en Lecture Seule et en Écriture Seule

Les properties offrent un contrôle précis sur l'accès aux attributs d'une classe. Bien qu'elles soient souvent utilisées pour fournir un accès contrôlé en lecture et en écriture, il est également possible de créer des properties en lecture seule ou en écriture seule, limitant ainsi l'interaction avec l'attribut sous-jacent.

Une property en lecture seule permet d'accéder à la valeur d'un attribut, mais empêche sa modification directe via l'attribut. Cela peut être utile pour exposer des valeurs calculées, des informations dérivées ou des constantes sans permettre à l'utilisateur de les altérer accidentellement.


class Computer:
    def __init__(self, brand, model, initial_price):
        self._brand = brand  # Convention: _attribute indicates it's "protected"
        self._model = model
        self._price = initial_price

    @property
    def brand(self):
        return self._brand

    @property
    def model(self):
        return self._model

    @property
    def price(self):
        return self._price

# Example usage
my_computer = Computer("Dell", "XPS 13", 1200)
print(f"Brand: {my_computer.brand}")
print(f"Model: {my_computer.model}")
print(f"Price: {my_computer.price}")

# Attempting modification (will raise an AttributeError)
# my_computer.brand = "Apple"  # This will raise an AttributeError

Dans cet exemple, les properties brand, model, et price sont en lecture seule. Tenter de modifier my_computer.brand, par exemple, résultera en une erreur AttributeError car il n'y a pas de setter défini pour cette property. L'absence de setter empêche toute modification externe de l'attribut.

Une property en écriture seule, moins courante, permet de modifier un attribut mais empêche de le lire directement. Son utilité est plus limitée, mais elle peut servir pour masquer des données sensibles, pour implémenter des mécanismes d'écriture avec des effets de bord (side effects) sans exposer la valeur directement, ou pour forcer l'utilisation d'une méthode spécifique pour obtenir une information.


import hashlib

class Password:
    def __init__(self):
        self._password_hash = None

    @property
    def password_hash(self):
        raise AttributeError("Read access to hashed password is not allowed.")

    @password_hash.setter
    def password_hash(self, new_password):
        # Hashing the password before storing it
        hashed_password = hashlib.sha256(new_password.encode()).hexdigest()
        self._password_hash = hashed_password
        print("Password hashed and updated.")

# Example usage
my_password = Password()

# Setting the password (hashed)
my_password.password_hash = "MyNewPassword"

# Attempting to read (will raise an AttributeError)
# print(my_password.password_hash)  # This will raise an AttributeError

Dans cet exemple, la property password_hash est en écriture seule. On peut définir sa valeur (qui sera automatiquement hashée), mais tenter d'y accéder en lecture lèvera une AttributeError. Ceci permet de sécuriser le stockage du mot de passe en empêchant son accès direct et non hashé.

En résumé, les properties en lecture seule et en écriture seule offrent une flexibilité accrue dans la gestion de l'accès aux attributs. Elles permettent de contrôler précisément comment les données sont exposées et modifiées au sein d'une classe, contribuant ainsi à une meilleure encapsulation et à une plus grande sécurité du code.

Voici un tableau récapitulatif des utilisations courantes :

  • Lecture Seule : Exposer des valeurs calculées, des informations dérivées ou des constantes tout en empêchant les modifications externes.
  • Écriture Seule : Masquer des données sensibles, implémenter des mécanismes d'écriture avec des effets de bord, ou forcer l'utilisation de méthodes spécifiques.

5.1 Création de Properties en Lecture Seule

Une property en lecture seule est une property qui expose une méthode getter, mais pas de méthode setter. Cela signifie qu'une fois l'attribut initialisé, sa valeur ne peut plus être modifiée directement de l'extérieur de la classe. Pour créer une property en lecture seule, il suffit de définir le getter.

Prenons l'exemple d'une classe Circle. Cette classe possède un attribut radius qui est défini lors de la création du cercle. Nous voulons que le rayon ne puisse pas être modifié une fois le cercle créé. Nous allons donc créer une property en lecture seule pour l'attribut radius.


import math

class Circle:
    def __init__(self, radius):
        self._radius = radius  # Convention for a "private" attribute
    
    @property
    def radius(self):
        # Getter for the radius property
        return self._radius
    
    @property
    def area(self):
        #Calculates the area, making it also a read-only property
        return math.pi * self._radius**2

# Creating an instance of the Circle class
my_circle = Circle(5)

# Accessing the radius property (read-only)
print(my_circle.radius)  # Output: 5

# Accessing the area property (read-only)
print(my_circle.area) # Output: 78.53981633974483

# Attempting to modify the radius property (will raise an error)
try:
    my_circle.radius = 10
except AttributeError as e:
    print(e)  # Output: can't set attribute

Dans cet exemple, la property radius est accessible en lecture seule. Toute tentative de modification de sa valeur en dehors de la classe entraînera une exception AttributeError, car aucun setter n'a été défini. De même, la property area, qui calcule l'aire du cercle en fonction de son rayon, est également en lecture seule. L'attribut _radius est considéré comme protégé et n'est pas censé être accédé directement, bien que Python ne l'empêche pas. Le caractère de soulignement (_) est une convention pour indiquer qu'un attribut est destiné à un usage interne à la classe.

En résumé, pour créer une property en lecture seule, il suffit d'implémenter le getter et d'omettre le setter. Cette technique est utile pour protéger des attributs importants et garantir leur intégrité en empêchant leur modification accidentelle ou non autorisée. On peut utiliser ce mécanisme pour définir des valeurs calculées à partir d'autres attributs, comme illustré avec la property area.

5.2 Création de Properties en Écriture Seule (rare)

Bien que moins courantes, les properties en écriture seule peuvent être utiles dans des scénarios spécifiques. Un tel scénario est celui où l'on souhaite enregistrer un événement ou modifier un état interne sans permettre la lecture directe de cette information de l'extérieur. Cela peut servir à masquer la complexité interne d'un objet ou à imposer un certain contrôle sur la façon dont les données sont modifiées.

Pour créer une property en écriture seule en Python, il suffit de définir uniquement le setter (la méthode décorée avec @property.setter) et d'omettre le getter. Voici un exemple illustratif :


class Journal:
    def __init__(self):
        self._log = []  # Internal log, not directly accessible

    @property
    def add_entry(self):
        # This is intentionally left empty to prevent reading.
        pass

    @add_entry.setter
    def add_entry(self, message):
        # Log the message with a timestamp.
        import datetime
        timestamp = datetime.datetime.now().isoformat()
        self._log.append(f'{timestamp}: {message}')

    def display_log(self):
        # Method to view the log (not through the property).
        for entry in self._log:
            print(entry)

# Example Usage
journal = Journal()
journal.add_entry = "System started"  # Writing to the log.
journal.add_entry = "User logged in"  # Writing to the log.
# journal.add_entry  # This would raise an AttributeError because the getter is missing
journal.display_log()  # Accessing the log through a method.

Dans cet exemple, la classe Journal maintient un journal interne (_log) qui est mis à jour via la property add_entry. On peut affecter une valeur à journal.add_entry, ce qui déclenche le setter et ajoute une entrée au journal. Cependant, tenter de lire la valeur de journal.add_entry provoquerait une erreur, car il n'y a pas de getter défini. La méthode display_log est fournie pour consulter le journal (si besoin), contournant ainsi la property.

Il est crucial de noter que l'utilisation de properties en écriture seule doit être envisagée avec précaution. Elles peuvent rendre le code moins intuitif et plus difficile à déboguer, car la lecture d'une propriété est un comportement attendu par défaut. Une documentation claire et une justification solide sont essentielles si vous choisissez d'adopter cette approche. Dans la plupart des cas, une méthode explicite (par exemple, log_event(message)) est préférable pour une meilleure lisibilité et maintenabilité du code.

Voici quelques points à considérer lors de l'utilisation de properties en écriture seule :

  • Intention claire : Assurez-vous que l'intention de ne pas permettre la lecture est claire et bien documentée.
  • Alternatives : Évaluez si une méthode explicite serait plus appropriée et plus lisible.
  • Effets de bord : Soyez conscient des effets de bord potentiels et assurez-vous qu'ils sont gérés de manière appropriée.

En résumé, bien que les properties en écriture seule puissent avoir leur utilité, elles doivent être utilisées avec discernement et avec une justification claire pour éviter de rendre le code plus confus et moins maintenable.

6. Héritage et Properties

L'héritage est un pilier de la programmation orientée objet (POO), permettant à une classe (la classe enfant ou sous-classe) d'acquérir les attributs et méthodes d'une autre (la classe parent ou super-classe). Les properties interagissent avec l'héritage, offrant un contrôle affiné sur l'accès et la modification des attributs hérités.

Lorsqu'une classe enfant hérite d'une classe parent qui définit des properties, elle hérite de ces properties comme des attributs ordinaires. La puissance réside dans la capacité de redéfinir (override) ces properties dans la classe enfant pour adapter leur comportement.

Illustrons cela avec un exemple. Prenons une classe Animal dotée d'une property pour l'âge.


class Animal:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        """Returns the animal's age."""
        return self._age

    @age.setter
    def age(self, value):
        """Sets the animal's age, ensuring it's not negative."""
        if value >= 0:
            self._age = value
        else:
            raise ValueError("Age cannot be negative.")

    def make_sound(self):
        print("Generic animal sound")

Créons maintenant une classe Dog qui hérite de Animal et redéfinit la property age pour introduire une validation spécifique aux chiens.


class Dog(Animal):
    def __init__(self, age, breed):
        super().__init__(age)
        self.breed = breed

    @Animal.age.setter
    def age(self, value):
        """Sets the dog's age, with a maximum age limit."""
        if value > 20:
            raise ValueError("Dog's age cannot exceed 20 years.")
        Animal.age.fset(self, value)  # Call the parent's setter

    def make_sound(self):
        print("Woof!")

Dans cet exemple, Dog hérite de la property age de Animal. On utilise @Animal.age.setter pour accéder au setter original défini dans la classe parent. On peut ensuite ajouter des contraintes spécifiques, comme une limite d'âge maximale pour les chiens, avant d'invoquer le setter de la classe parent via Animal.age.fset(self, value) pour effectuer la mise à jour de l'attribut _age.

Il est crucial de noter que si l'on ne souhaite pas altérer le comportement d'une property, il suffit de l'hériter sans la redéfinir. Dans ce cas, la property de la classe parent sera utilisée telle quelle.

Il est également possible de définir de nouvelles properties dans la classe enfant, basées sur les attributs hérités de la classe parent.


class Cat(Animal):
    def __init__(self, age):
        super().__init__(age)

    @property
    def human_age(self):
        """Calculates the approximate human age equivalent for a cat."""
        return self.age * 7

    def make_sound(self):
        print("Meow!")

Ici, la classe Cat hérite de l'attribut age de Animal et définit une nouvelle property, human_age, qui calcule une approximation de l'âge du chat en années humaines. Cette property utilise la property age héritée de la classe parent.

En résumé, l'héritage et les properties se combinent harmonieusement pour favoriser une conception de classes souple et maintenable. L'héritage facilite la réutilisation du code et l'établissement de relations "est-un" entre les classes, tandis que les properties offrent un contrôle précis sur l'accès et la modification des attributs, permettant une encapsulation et une validation efficaces, même dans le contexte de l'héritage. Les properties offrent notamment les avantages suivants:

  • Encapsulation: Masquer l'implémentation interne des attributs.
  • Validation: Assurer que les valeurs assignées aux attributs sont valides.
  • Calcul: Calculer des valeurs à la demande, basées sur d'autres attributs.

6.1 Héritage des properties

L'un des avantages fondamentaux des properties réside dans leur capacité d'héritage par les classes filles, à l'instar des attributs et méthodes classiques. Cette caractéristique promeut la réutilisation et l'extension de la logique encapsulée dans la classe parente. Une classe fille a la liberté de conserver le comportement hérité d'une property ou de le redéfinir pour satisfaire ses exigences spécifiques.

La redéfinition d'une property héritée se déroule d'une manière similaire à la redéfinition d'une méthode. Il est possible de modifier le getter, le setter ou le deleter, ou même de remplacer complètement la property par une nouvelle implémentation. Cette flexibilité permet d'adapter précisément le comportement des properties aux particularités de chaque classe fille.

Illustrons cela avec un exemple impliquant une classe de base Forme et une classe dérivée Carre. La classe Forme pourrait inclure une property surface calculée, tandis que la classe Carre pourrait redéfinir cette property pour tenir compte de la nature spécifique d'un carré, à savoir que tous ses côtés sont égaux. Un exemple complet suit :


class Forme:
    def __init__(self, largeur, hauteur):
        self._largeur = largeur
        self._hauteur = hauteur

    @property
    def surface(self):
        """Calculates the surface of the shape."""
        return self._largeur * self._hauteur

    @property
    def largeur(self):
        return self._largeur

    @largeur.setter
    def largeur(self, value):
        if value <= 0:
            raise ValueError("La largeur doit être positive.")
        self._largeur = value

    @property
    def hauteur(self):
        return self._hauteur

    @hauteur.setter
    def hauteur(self, value):
        if value <= 0:
            raise ValueError("La hauteur doit être positive.")
        self._hauteur = value


class Carre(Forme):
    def __init__(self, cote):
        super().__init__(cote, cote)

    @property
    def surface(self):
        """Calculates the surface of the square."""
        return self._largeur * self._largeur

    @property
    def cote(self):
        """Returns the side length of the square."""
        return self._largeur

    @cote.setter
    def cote(self, value):
        """Sets the side length of the square."""
        if value <= 0:
            raise ValueError("Le côté doit être positif.")
        self._largeur = value
        self._hauteur = value


# Example Usage
carre = Carre(5)
print(f"Surface du carré : {carre.surface}")
carre.cote = 10
print(f"Nouvelle surface du carré : {carre.surface}")

forme = Forme(4,6)
print(f"Surface de la forme : {forme.surface}")

try:
    carre.cote = -2
except ValueError as e:
    print(e)

Dans cet exemple, la classe Carre hérite des attributs largeur et hauteur de la classe Forme, ainsi que de la possibilité d'utiliser la property surface. Cependant, elle redéfinit la property surface pour calculer la surface spécifique d'un carré en utilisant uniquement la longueur du côté. De plus, elle introduit une property cote, qui encapsule la longueur du côté, assurant que la largeur et la hauteur restent égales et facilitant la manipulation de la dimension du carré. Voici un résumé des points clés illustrés par cet exemple :

  • Héritage : Carre hérite des propriétés de Forme, démontrant la réutilisation du code.
  • Redéfinition : Carre redéfinit surface pour un calcul spécifique aux carrés.
  • Encapsulation : La property cote encapsule la logique de définition de la largeur et de la hauteur simultanément.

Ainsi, l'héritage et la redéfinition des properties permettent aux classes filles d'adapter leur comportement tout en conservant une structure cohérente avec la classe parente, ce qui conduit à un code plus propre, plus maintenable et plus expressif.

6.2 Redéfinition des properties dans les classes filles

L'héritage permet aux classes filles d'hériter des properties définies dans leurs classes parentes. Pour redéfinir une property dans une classe fille, utilisez la même syntaxe que pour la définition d'une nouvelle property. Les décorateurs @property, @nom.setter et @nom.deleter sont utilisés pour personnaliser le comportement de la property héritée.

Il est possible d'étendre ou de modifier le comportement d'une property héritée en appelant la méthode correspondante de la classe parente via super(). Cela permet de réutiliser la logique existante tout en ajoutant des fonctionnalités spécifiques à la classe fille. Il est crucial de bien comprendre comment super() fonctionne pour éviter des comportements inattendus, surtout lors de l'utilisation de l'héritage multiple.

Voici un exemple pour illustrer ce concept :


class Instrument:
    def __init__(self, name, price):
        self._name = name
        self._price = price

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

    @name.setter
    def name(self, new_name):
        if not isinstance(new_name, str):
            raise ValueError("Name must be a string.")
        self._name = new_name

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, new_price):
        if not isinstance(new_price, (int, float)):
            raise ValueError("Price must be a number.")
        if new_price < 0:
            raise ValueError("Price cannot be negative.")
        self._price = new_price

class Guitar(Instrument):
    def __init__(self, name, price, string_type):
        super().__init__(name, price)
        self._string_type = string_type

    @property
    def price(self):
        # Increase the price for guitars
        return super().price * 1.1

    @price.setter
    def price(self, new_price):
        # Add specific validation for guitars
        if new_price > 10000:
            raise ValueError("The guitar price is too high.")
        super().price = new_price  # Call the setter of the parent class

    @property
    def string_type(self):
        return self._string_type

    @string_type.setter
    def string_type(self, new_type):
        self._string_type = new_type

# Example usage
guitar = Guitar("Stratocaster", 800, "Nylon")
print(f"Initial guitar price: {guitar.price}")

guitar.price = 900
print(f"New guitar price: {guitar.price}")

try:
    guitar.price = 12000
except ValueError as e:
    print(e)

Dans cet exemple, la classe Guitar hérite de la classe Instrument. La property price est redéfinie dans Guitar pour augmenter le prix de base de 10% et pour ajouter une validation supplémentaire. L'appel à super().price dans le getter permet de réutiliser la logique de la property price de la classe Instrument et d'appliquer la majoration. Dans le setter, super().price = new_price appelle directement le setter de la classe parente après avoir effectué la validation spécifique à la guitare.

Il est important de noter que l'appel à super() recherche la méthode dans l'ordre de résolution des méthodes (MRO). Le MRO est l'ordre dans lequel Python recherche les méthodes dans une hiérarchie de classes, et il est calculé automatiquement pour assurer un comportement prévisible lors de l'héritage multiple.

Voici quelques points importants à retenir concernant la redéfinition des properties :

  • @property : Définit la méthode comme un getter.
  • @nom.setter : Permet de définir un setter pour la property nommée nom.
  • super() : Appelle la méthode de la classe parente. Indispensable pour étendre le comportement sans réécrire la logique existante.
  • L'ordre de résolution des méthodes (MRO) détermine l'ordre dans lequel les classes parentes sont recherchées lors de l'appel à super().

L'exemple précédent montre une manière simple de redéfinir une property. Dans des scénarios plus complexes, notamment avec l'héritage multiple, il est crucial de bien comprendre le MRO et l'interaction entre les différentes classes pour éviter des comportements inattendus.

Conclusion

Les properties en Python constituent un mécanisme puissant pour encapsuler l'accès aux attributs d'une classe. Elles permettent de substituer un accès direct à un attribut par l'appel à des méthodes (getter, setter, deleter), offrant ainsi une grande flexibilité pour intégrer des logiques métiers complexes telles que la validation des données ou le calcul d'attributs dérivés. L'avantage principal réside dans la transparence pour l'utilisateur de la classe, qui manipule l'attribut comme si c'était un attribut public, alors qu'en réalité, des opérations spécifiques sont exécutées en arrière-plan.

L'utilisation des décorateurs @property, @nom.setter, et @nom.deleter simplifie grandement la définition des properties. Ces décorateurs permettent d'associer des méthodes aux opérations d'accès, de modification et de suppression d'un attribut. Prenons l'exemple d'une classe représentant une température, où l'on souhaite contrôler les valeurs assignées :


class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        # Get the celsius value
        return self._celsius

    @celsius.setter
    def celsius(self, new_celsius):
        # Validate the new celsius value
        if new_celsius < -273.15:
            raise ValueError("Temperature cannot be below absolute zero")
        self._celsius = new_celsius

    @celsius.deleter
    def celsius(self):
        # Prevent deletion of the celsius attribute
        raise AttributeError("Cannot delete the celsius attribute")


# Example Usage
temp = Temperature(25)
print(temp.celsius) # Accessing the property

temp.celsius = 30 # Setting the property
print(temp.celsius)

try:
    temp.celsius = -300  # Attempting to set an invalid value
except ValueError as e:
    print(e)

try:
    del temp.celsius  # Attempting to delete the property
except AttributeError as e:
    print(e)

Dans cet exemple, la propriété celsius encapsule l'attribut _celsius. L'accès à temp.celsius exécute la méthode décorée avec @property (le getter). L'assignation d'une nouvelle valeur à temp.celsius exécute la méthode décorée avec @celsius.setter, qui effectue une validation. Enfin, la tentative de suppression de l'attribut lève une exception, interdisant sa suppression.

Les properties offrent plusieurs avantages clés :

  • Encapsulation : Masquent l'implémentation interne des attributs, protégeant ainsi l'état de l'objet.
  • Validation : Permettent de valider les données lors de l'assignation, garantissant la cohérence de l'objet.
  • Calcul d'attributs : Permettent de calculer des attributs à la volée, évitant ainsi de stocker des données redondantes.
  • Flexibilité : Offrent la possibilité de modifier l'implémentation interne sans impacter le code client.

En conclusion, les properties sont un outil essentiel pour écrire du code Python orienté objet de haute qualité. Elles permettent de respecter les principes d'encapsulation, de contrôle d'accès et de responsabilité unique, conduisant à un code plus propre, plus maintenable et plus robuste.

That's all folks