Pourquoi l'immuabilité est-elle si importante

Bonjour, Habr!



Aujourd'hui, nous voulons aborder le sujet de l'immuabilité et voir si ce problème mérite une considération plus sérieuse.



Les objets immuables sont un phénomène extrêmement puissant en programmation. L'immuabilité vous aide à éviter toutes sortes de problèmes de concurrence et de nombreux bogues, mais comprendre les constructions immuables peut être délicat. Jetons un coup d'œil à ce qu'ils sont et comment les utiliser.



Tout d'abord, jetez un œil à un objet simple:



class Person {
    public String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
}


Comme vous pouvez le voir, l'objet Personprend un paramètre dans son constructeur et le place ensuite dans une variable publique name. En conséquence, nous pouvons faire des choses comme ceci:



Person p = new Person("John");
p.name = "Jane";


Simple, non? A tout moment, lisez ou modifiez les données à notre guise. Mais il y a quelques problèmes avec cette méthode. Le premier et le plus important d'entre eux est que nous utilisons une variable dans notre classe name, et donc, introduisons irrévocablement le stockage interne de la classe dans l'API publique. En d'autres termes, il n'y a aucun moyen de changer la façon dont le nom est stocké dans la classe, à moins de réécrire une partie importante de notre application.



Certains langages (par exemple, C #) offrent la possibilité d'insérer une fonction getter pour contourner ce problème, mais dans la plupart des langages orientés objet, vous devez agir explicitement:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}


Jusqu'ici tout va bien. Si vous souhaitez maintenant modifier la mémoire interne du nom, par exemple, en prénom et nom, vous pouvez le faire:



class Person {
    private String firstName;
    private String lastName;
    
    public Person(
        String firstName,
        String lastName
    ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    
    public String getName() {
        return firstName + " " + lastName;
    }
}


Si vous ne vous penchez pas sur les graves problèmes associés à une telle représentation des noms , il est évident que l'API n'a getName()pas changé en externe .



Et la définition des noms? Que devez-vous ajouter non seulement pour obtenir le nom, mais également pour le définir comme ceci?



class Person {
    private String name;
    
    //...
    
    public void setName(String name) {
        this.name = name;
    }
    
    //...
}


À première vue, cela a l'air génial, car maintenant nous pouvons à nouveau changer le nom. Mais il y a un défaut fondamental dans cette manière de modifier les données. Il a deux côtés: philosophique et pratique.



Commençons par un problème philosophique. L'objet est Persondestiné à représenter une personne. En effet, le nom de famille d'une personne peut changer, mais il serait préférable de nommer une fonction à cet effet changeName, car un tel nom implique que l'on change le nom de famille de la même personne. Il devrait également inclure une logique métier pour changer le nom de famille d'une personne, et pas seulement agir comme un passeur. Le nom setNameconduit à une conclusion tout à fait logique que nous pouvons changer volontairement et obligatoirement le nom stocké dans l'objet personne, et nous n'obtiendrons rien pour cela.



La deuxième raison a à voir avec la pratique: l'état mutable (données stockées qui peuvent changer) est sujet aux bogues. Prenons cet objet Personet définissons une interface PersonStorage:



interface PersonStorage {
    public void store(Person person);
    public Person getByName(String name);
}


Remarque: cela PersonStoragen'indique pas exactement où l'objet est stocké: en mémoire, sur disque ou dans une base de données. L'interface ne nécessite pas non plus d'implémentation pour créer une copie de l'objet qu'elle stocke. Par conséquent, un bug intéressant peut survenir:



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);


Combien de personnes se trouvent actuellement dans le magasin personnel? Un ou deux? D'ailleurs, si nous appliquons maintenant la méthode getByName, quelle personne reviendra-t-elle?



Comme vous pouvez le voir, deux options sont possibles ici: soit il PersonStoragecopiera l'objet Person, auquel cas deux enregistrements seront sauvegardés Person, soit il ne le fera pas, et n'enregistrera que la référence à l'objet passé; dans le second cas, un seul objet portant le nom sera enregistré “Jane”. La mise en œuvre de la deuxième option pourrait ressembler à ceci:



class InMemoryPersonStorage implements PersonStorage {
    private Set<Person> persons = new HashSet<>();

    public void store(Person person) {
        this.persons.add(person);
    }
}


Pire encore, les données stockées peuvent être modifiées sans même appeler la fonction store. Étant donné que le référentiel ne contient qu'une référence à l'objet d'origine, la modification du nom modifiera également la version enregistrée:



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");


Donc, en substance, les bogues s'insinuent dans notre programme précisément parce que nous avons affaire à un état mutable. Il ne fait aucun doute que ce problème peut être contourné en écrivant explicitement le travail de création d'une copie dans le stockage, mais il existe un moyen beaucoup plus simple: travailler avec des objets immuables. Prenons un exemple:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    
    public Person withName(String name) {
        return new Person(name);
    }
}


Comme vous pouvez le voir, au lieu d'une méthode, une méthode est setNamemaintenant utilisée withNamequi crée une nouvelle copie de l'objet Person. Si nous créons une nouvelle copie à chaque fois, alors nous faisons sans état mutable et sans les problèmes correspondants. Bien sûr, cela entraîne une surcharge, mais les compilateurs modernes peuvent le gérer et si vous rencontrez des problèmes de performances, vous pouvez les résoudre plus tard.



Rappelles toi:

L'optimisation prématurée est la racine de tout mal (Donald Knuth)


On pourrait faire valoir que le niveau de persistance qui fait référence à l'objet vivant est un niveau de persistance cassé, mais un tel scénario est réaliste. Un mauvais code existe et l'immuabilité est un outil précieux pour aider à prévenir de telles ruptures.



Dans des scénarios plus complexes, où les objets passent à travers plusieurs couches de l'application, les bogues inondent facilement le code et l'immuabilité empêche les bogues d'état de se produire. Des exemples de ce type incluent, par exemple, la mise en cache en mémoire ou les appels de fonction dans le désordre.



Comment l'immuabilité contribue au traitement parallèle



Un autre domaine important dans lequel l'immuabilité est utile est le traitement parallèle. Plus précisément, le multithreading. Dans les applications multithreads, plusieurs lignes de code sont exécutées en parallèle, qui, en même temps, accèdent à la même zone mémoire. Considérez une liste très simple:



if (p.getName().equals("John")) {
    p.setName(p.getName() + "Doe");
}


Ce code n'est pas bogué en lui-même, mais lorsqu'il est exécuté en parallèle, il commence à préempter et peut devenir désordonné. Découvrez à quoi ressemble l'extrait de code ci-dessus avec un commentaire:



if (p.getName().equals("John")) {

    //     ,     John
    
    p.setName(p.getName() + "Doe");
}


C'est une condition de course. Le premier thread vérifie si le nom est égal “John”, mais le deuxième thread change le nom. Le premier thread continue de s'exécuter, en supposant toujours que le nom est égal John.



Bien sûr, on pourrait utiliser le verrouillage pour s'assurer qu'un seul thread entre dans la partie critique du code à un moment donné, cependant, il peut y avoir un goulot d'étranglement. Cependant, si les objets sont immuables, un tel scénario ne peut pas se développer, car le même objet est toujours stocké dans p. Si un autre thread veut influencer la modification, il crée une nouvelle copie qui ne sera pas dans le premier thread.



Résultat



Fondamentalement, mon conseil serait de toujours m'assurer que l'état mutable est minimisé dans votre application. Si vous l'utilisez, limitez-le étroitement avec des API bien conçues, ne le laissez pas s'infiltrer dans d'autres domaines de l'application. Moins vous avez de morceaux de code contenant un état, moins vous risquez de détecter des erreurs d'état.



Bien sûr, la plupart des problèmes de programmation ne peuvent pas être résolus si vous n'utilisez pas du tout l'état. Mais si nous considérons toutes les structures de données comme immuables par défaut, alors il y aura beaucoup moins de bogues aléatoires dans le code. Si vous êtes vraiment obligé d'introduire la mutabilité dans le code, alors vous devrez le faire soigneusement et réfléchir aux conséquences, et ne pas commencer tout le code avec.



All Articles