31 gennaio 2021

Proprietà getters e setters

Esistono due tipi di proprietà per gli oggetti.

Il primo tipo sono le data properties (proprietà di tipo “dato”). Sappiamo già come utilizzarle, poiché tutte le proprietà viste fino ad ora erano date properties.

Il secondo tipo di proprietà è qualcosa di nuovo. Sono definite accessor properties (proprietà accessorie). Sono essenzialmente funzioni che vengono eseguite quando viene letto o impostato un valore, ma al codice esterno appaiono come normali proprietà.

Getters e setters

Le accessor properties sono rappresentate dai metodi “getter” e “setter”. In un object literal vengono indicate da get e set:

let obj = {
  get propName() {
    // getter, il codice eseguito per ottenere obj.propName
  },

  set propName(value) {
    // setter, il codice eseguito per impostare il valore di obj.propName = value
  }
};

La proprietà getter viene eseguita quando obj.propName viene letto, la proprietà setter, invece, quando viene assegnato.

Ad esempio, abbiamo un oggetto user con le proprietà name e surname:

let user = {
  name: "John",
  surname: "Smith"
};

Ora vogliamo aggiungere una proprietà fullName, che dovrebbe valere "John Smith". Ovviamente vorremmo evitare di copiare ed incollare informazioni già esistenti, quindi possiamo implementare questa funzionalità tramite un accessor:

let user = {
  name: "John",
  surname: "Smith",

  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

alert(user.fullName); // John Smith

Vista esternamente, una accessor property è del tutto simile ad una normale proprietà, è questa l’idea che sta dietro alle accessor properties. Non invochiamo user.fullName come una normale funzione, ma la leggiamo come una normale proprietà: in questo caso il getter sta lavorando per noi.

Per ora, fullName possiede un solo getter. Se provassimo ad assegnare user.fullName=, otterremo un errore:

let user = {
  get fullName() {
    return `...`;
  }
};

user.fullName = "Test"; // Error (la proprietà possiede solo un getter)

Aggiungiamo quindi un setter per user.fullName:

let user = {
  name: "John",
  surname: "Smith",

  get fullName() {
    return `${this.name} ${this.surname}`;
  },

  set fullName(value) {
    [this.name, this.surname] = value.split(" ");
  }
};

// set fullName viene eseguito con i valori forniti
user.fullName = "Alice Cooper";

alert(user.name); // Alice
alert(user.surname); // Cooper

Come risultato finale, abbiamo un proprietà “virtuale” fullName. Che possiamo sia leggere che scrivere.

Descrittori degli accessors

I descrittori per le accessor prorperties sono diversi da quelli per le data properties.

Per le accessor properties, non ci sono value o writable, ma ci sono invece le funzioni get or set.

Un descrittore di accessor properties può possedere:

  • get – una funzione che non accetta argomenti, che specifica come accedere in lettura ad una proprietà,
  • set – una funzione con un solo argomento, che specifica come impostare il valore della proprietà,
  • enumerable – stesso comportamento visto per le data properties,
  • configurable – stesso comportamento visto per le data properties.

Ad esempio, possiamo creare un accessor fullName con defineProperty, passando un descriptor con get e set:

let user = {
  name: "John",
  surname: "Smith"
};

Object.defineProperty(user, 'fullName', {
  get() {
    return `${this.name} ${this.surname}`;
  },

  set(value) {
    [this.name, this.surname] = value.split(" ");
  }
});

alert(user.fullName); // John Smith

for(let key in user) alert(key); // name, surname

Da notare che una proprietà può essere o un accessor (con i metodi get/set) o una data property (con un value), ma non entrambe.

Se proviamo a fornire sia get che value, nello stesso descriptor, otterremo un errore:

// Error: Invalid property descriptor.
Object.defineProperty({}, 'prop', {
  get() {
    return 1
  },

  value: 2
});

Getters/setters intelligenti

Getters/setters possono essere utilizzati come wrappers (contenitori) per le proprietà “reali”, in questo modo avremo più controllo sulle operazioni di lettura/scrittura.

Ad esempio, potremmo vietare nomi troppo brevi per la proprietà name, possiamo definire un setter name e mantenere il valore in una proprietà diversa _name:

let user = {
  get name() {
    return this._name;
  },

  set name(value) {
    if (value.length < 4) {
      alert("Name is too short, need at least 4 characters");
      return;
    }
    this._name = value;
  }
};

user.name = "Pete";
alert(user.name); // Pete

user.name = ""; // Il nome è troppo corto...

Quindi, il nome viene memorizzato nella prorietà _name, e gli accessi vengono effettuati tramite getter e setter.

Tecnicamente, il codice all’esterno potrebbe accedere direttamente al nome utilizzando user._name. Ma esiste una convezione molto diffusa che specifica di non utilizzare direttamente le proprietà che iniziano con "_".

Utilizzato per compatibilità

Uno dei principali vantaggi offerti dagli accessors è che permettono di migliorare il controllo di una normale data property rimpiazzandola con le proprietà getter e setter e lavorando sul loro comportamento.

Immaginiamo di inziare ad implementare l’oggetto user con le proprietà name e age°

function User(name, age) {
  this.name = name;
  this.age = age;
}

let john = new User("John", 25);

alert( john.age ); // 25

…Ma prima o poi, le cose potrebbero cambiare. Invece di age potremmo decidere di memorizzare birthday, poiché è più preciso e conveniente:

function User(name, birthday) {
  this.name = name;
  this.birthday = birthday;
}

let john = new User("John", new Date(1992, 6, 1));

Ora come ci comportiamo con il codice “vecchio” che utilizza ancora la proprietà age?

Possiamo provare a cercare tutti i posti in cui viene utilizzata nel codice e sistemarlo, ma questo potrebbe richiedere tempo e potrebbe essere ancora più complesso se lo stesso codice viene utilizzato da altre persone. E in ogni caso, age è una proprietà utile da avere in user, giusto?

Quindi manteniamola.

Aggiungere un getter per age risolve il problema:

function User(name, birthday) {
  this.name = name;
  this.birthday = birthday;

  // age viene calcolata utilizzando la data attuale ed il compleanno
  Object.defineProperty(this, "age", {
    get() {
      let todayYear = new Date().getFullYear();
      return todayYear - this.birthday.getFullYear();
    }
  });
}

let john = new User("John", new Date(1992, 6, 1));

alert( john.birthday ); // birthday è disponibile
alert( john.age );      // ...è lo è anche age

In questo modo il codice “vecchio” continua a funzionare e abbiamo anche guadagnato un’ottima proprietà aggiuntiva.

Mappa del tutorial

Commenti

leggi questo prima di lasciare un commento…
  • Per qualsiasi suggerimento - per favore, apri una issue su GitHub o una pull request, piuttosto di lasciare un commento.
  • Se non riesci a comprendere quanto scitto nell'articolo – ti preghiamo di fornire una spiegazione chiara.
  • Per inserire delle righe di codice utilizza il tag <code>, per molte righe – includile nel tag <pre>, per più di 10 righe – utilizza una sandbox (plnkr, jsbin, codepen…)