I descrittori sono stati formalizzati in Python 2.2 insieme alle classi di stile nuovo per fornire un protocollo unificato per il controllo dell'accesso agli attributi. Prima di questa innovazione, i tipi incorporati come property e classmethod si basavano su una logica a caso hardcoded nel interprete. L'introduzione del protocollo dei descrittori ha permesso alle classi definite dall'utente di esibire comportamenti precedentemente riservati agli incorporati. La convenzione di passare None per il parametro dell'istanza è emersa organicamente dalla necessità di distinguere tra l'accesso a livello di classe e a livello di istanza senza frammentare il protocollo in più metodi.
Senza un meccanismo per rilevare quando l'accesso avviene sulla classe stessa, i descrittori sarebbero stati costretti a restituire se stessi incondizionatamente, impedendo l'implementazione di proprietà a livello di classe o l'introspezione dello schema. In alternativa, il protocollo richiederebbe metodi di hook separati per l'accesso a classe e a istanza, complicando notevolmente il modello ad oggetti. La sfida risiedeva nel progettare una singola firma di metodo in grado di gestire elegantemente entrambi i modelli di accesso mantenendo la compatibilità retroattiva e un sovraccarico di prestazioni minimo.
La firma del metodo __get__(self, instance, owner) riceve None per il parametro instance quando accesso come Class.attribute, e il vero oggetto istanza quando accesso come instance.attribute. Il parametro owner riceve sempre la classe definita. Questo consente ai descrittori di implementare logica ramificata: restituendo metadati o il descrittore stesso quando instance è None, o restituendo valori calcolati quando un'istanza esiste. Questa convenzione permette l'implementazione di classmethod e staticmethod in puro Python, e supporta schemi di validazione a livello di classe avanzati.
Un team di ingegneria dei dati richiedeva un framework di validazione dichiarativa dove le definizioni dei campi fornissero metadati quando ispezionati sulla classe per la generazione automatica della documentazione OpenAPI, ma eseguivano validazione dei dati quando accessibili su istanze. L'implementazione iniziale usando descrittori naivi fallì poiché accedere a User.email sulla classe restituiva l'oggetto descrittore grezzo, offrendo nessuna informazione di tipo o vincoli.
Un approccio considerato era implementare metodi di classe separati per il recupero dei metadati. Ciò comportava la creazione di un metodo get_schema() che ispezionava manualmente il dizionario della classe per estrarre le informazioni sui campi. Sebbene esplicito e facilmente comprensibile per i programmatori junior, questo creò una pericolosa disconnessione tra le definizioni dei campi e le loro capacità di introspezione. Pro: Implementazione semplice senza necessità di conoscenze avanzate di Python. Contro: Violava il principio DRY, richiedeva la manutenzione di strutture logiche parallele ed era soggetto a errori quando le definizioni dei campi evolvevano.
Il secondo approccio sfruttava la convenzione None del protocollo dei descrittori controllando if instance is None all'interno di __get__. Quando questa condizione era vera, il descrittore restituiva un oggetto FieldSchema contenente vincoli di tipo e validatori; altrimenti, eseguiva la validazione e restituiva il valore reale. Pro: API unificata sotto un singolo nome di attributo, seguendo le convenzioni Pythonic, e fornendo supporto automatico all'ereditarietà. Contro: Richiedeva una profonda comprensione del meccanismo di ricerca degli attributi di CPython e si rivelava più difficile da debuggare per gli sviluppatori non familiari con gli interni dei descrittori.
Una terza opzione comportava l'uso di una metaclasse per intercettare la creazione di classi e iniettare proprietà sintetiche per l'accesso agli schemi. Sebbene questo offrisse un controllo totale sul comportamento della classe, introduceva una complessità significativa nella gerarchia delle classi e complicava gli sforzi di debug. Pro: Controllo totale del comportamento. Contro: Eccessivamente ingegnerizzato per i requisiti, influenzava i calcoli dell'ordine di risoluzione dei metodi e aumentava notevolmente il sovraccarico del tempo di importazione.
Il team scelse la seconda soluzione perché utilizzava meccanismi esistenti di CPython senza introdurre layer di astrazione aggiuntivi. Il controllo None forniva sufficiente contesto per distinguere tra i modelli di accesso al momento della documentazione e al momento dell'esecuzione, riducendo il codice di quaranta percento rispetto all'approccio del metodo esplicito.
Il framework risultante consentì a User.email di restituire un oggetto schema completo, mentre user.email restituiva il valore stringa validato. Questo comportamento duale abilita la generazione automatica della specifica OpenAPI attraverso una semplice ispezione della classe, riducendo la manutenzione della documentazione di novanta percento ed eliminando un'intera categoria di bug di sincronizzazione tra implementazione e documentazione.
Come differiscono i descrittori dei dati (implementando sia __get__ che __set__) dai descrittori non-dati nella precedenza di ricerca degli attributi, e perché questa distinzione impedisce ai dizionari delle istanze di oscurare gli attributi della classe in alcuni casi ma non in altri?
I descrittori dei dati implementano sia __get__ che __set__, mentre i descrittori non-dati implementano solo __get__. Nel meccanismo di risoluzione degli attributi di Python, i descrittori dei dati hanno precedenza sul __dict__ dell'istanza. Ciò significa che l'assegnazione a instance.attr invocherà sempre il metodo __set__ del descrittore, anche se l'istanza aveva precedentemente quella chiave nel suo dizionario. Al contrario, i descrittori non-dati permettono al dizionario dell'istanza di oscurarli; se assegni instance.attr = value, l'istanza guadagna una nuova voce nel __dict__, e gli accessi successivi recuperano questo valore invece di invocare il descrittore. Questa distinzione è cruciale per implementare proprietà cache (non-dati) rispetto ad attributi di sola lettura (dati). I candidati trascurano spesso che definire semplicemente __set__ cambia la semantica di ricerca anche se il metodo solleva semplicemente AttributeError, che è esattamente come gli oggetti property impongono l'immutabilità.
Perché i descrittori personalizzati devono implementare __set_name__ piuttosto che catturare il nome dell'attributo in __init__, particolarmente quando la stessa istanza del descrittore è assegnata a più attributi della classe o usata con l'ereditarietà?
Quando una singola istanza di descrittore è assegnata a più nomi (es. x = y = MyDescriptor()), memorizzare il nome in __init__ porta a far sì che la seconda assegnazione sovrascriva la prima, portando a una risoluzione del nome scorretta. Inoltre, durante l'ereditarietà della classe, i descrittori della classe padre non vengono ri-inizializzati per le sottoclassi. Il metodo __set_name__, introdotto in Python 3.6, è invocato dall'interprete esattamente una volta durante la creazione della classe, ricevendo sia la classe proprietaria che il nome dell'attributo. Questo assicura un binding corretto anche con ereditarietà complesse o più assegnazioni. Senza questo metodo, i descrittori non possono generare messaggi di errore accurati o eseguire introspezione richiedente il loro nome di attributo, risultando in fallimenti silenziosi durante le operazioni di metaprogrammazione.
Come interagisce il protocollo dei descrittori con __slots__, e quale specifico modo di errore si verifica quando un descrittore personalizzato in una classe con slot condivide il suo nome con uno slot?
Il meccanismo __slots__ di Python implementa internamente descrittori di dati per gestire la memorizzazione degli attributi in array di dimensioni fisse piuttosto che in dizionari. Quando definisci __slots__ = ['name'], CPython crea un descrittore per name nel dizionario della classe. Se successivamente definisci un descrittore personalizzato con def name(self): ..., sovrascrivi il descrittore dello slot, rompendo completamente il meccanismo degli slot. Questo causa un AttributeError poiché il descrittore personalizzato manca dei protocolli slot a livello C necessari per accedere alla memorizzazione dello slot. I candidati spesso trascurano che i descrittori degli slot sono descrittori di dati con implementazioni C specializzate. La soluzione richiede di utilizzare un nome di attributo distinto per il descrittore personalizzato o di delegare accuratamente ai metodi __get__ e __set__ del descrittore originale, sebbene ciò richieda una gestione rigorosa per prevenire la ricorsione infinita.