I metodi virtuali sono comunemente implementati tramite le cosiddette tabelle dei metodi virtuali (vtable in breve), in cui sono memorizzati i puntatori a funzione. Questo aggiunge un riferimento indiretto alla chiamata effettiva (devo recuperare l'indirizzo della funzione da chiamare dalla vtable, quindi chiamarla - invece di chiamarla subito). Ovviamente, ci vuole un po 'di tempo e un po' più di codice.
Tuttavia, non è necessariamente la causa principale della lentezza. Il vero problema è che il compilatore (generalmente / di solito) non può sapere quale funzione verrà chiamata. Quindi non può incorporarlo o eseguire altre ottimizzazioni del genere. Questo da solo potrebbe aggiungere una dozzina di istruzioni inutili (preparare registri, chiamare, quindi ripristinare lo stato in seguito) e potrebbe inibire altre ottimizzazioni apparentemente non correlate. Inoltre, se ti ramifichi come un matto chiamando molte implementazioni diverse, subisci gli stessi colpi che potresti subire ramificando come un matto con altri mezzi: il predittore di cache e branch non ti aiuterà, i rami impiegheranno più tempo di un perfettamente prevedibile ramo.
Grande ma : questi successi di solito sono troppo piccoli per essere importanti. Vale la pena considerare se si desidera creare un codice ad alte prestazioni e considerare l'aggiunta di una funzione virtuale che verrebbe chiamata a una frequenza allarmante. Tuttavia, anche tenere presente che la sostituzione di chiamate di funzione virtuale con altri mezzi di ramificazione ( if .. else
, switch
, puntatori a funzione, ecc) non risolve il problema fondamentale - può benissimo essere più lenta. Il problema (se esiste) non sono le funzioni virtuali ma l'indirizzamento (non necessario).
Modifica: la differenza nelle istruzioni di chiamata è descritta in altre risposte. Fondamentalmente, il codice per una chiamata statica ("normale") è:
- Copia alcuni registri nello stack, per consentire alla funzione chiamata di utilizzare quei registri.
- Copia gli argomenti in posizioni predefinite, in modo che la funzione chiamata possa trovarli indipendentemente da dove viene chiamata.
- Inserisci l'indirizzo di ritorno.
- Diramazione / salto al codice della funzione, che è un indirizzo in fase di compilazione e quindi codificato nel file binario dal compilatore / linker.
- Ottieni il valore di ritorno da una posizione predefinita e ripristina i registri che vogliamo utilizzare.
Una chiamata virtuale fa esattamente la stessa cosa, tranne per il fatto che l'indirizzo della funzione non è noto al momento della compilazione. Invece, un paio di istruzioni ...
- Ottieni il puntatore vtable, che punta a una matrice di puntatori di funzione (indirizzi di funzione), uno per ogni funzione virtuale, dall'oggetto.
- Ottieni l'indirizzo della funzione corretta dalla vtable in un registro (l'indice in cui è memorizzato l'indirizzo della funzione corretta viene deciso al momento della compilazione).
- Passa all'indirizzo nel registro, anziché passare a un indirizzo hardcoded.
Per quanto riguarda i rami: un ramo è tutto ciò che salta a un'altra istruzione invece di lasciare semplicemente l'esecuzione dell'istruzione successiva. Questo include if
, switch
parti di vari loop, chiamate di funzione, ecc. E talvolta il compilatore implementa cose che non sembrano ramificarsi in un modo che ha effettivamente bisogno di un ramo sotto il cofano. Vedere Perché l'elaborazione di una matrice ordinata è più veloce di una matrice non ordinata? per quale motivo potrebbe essere lento, cosa fanno le CPU per contrastare questo rallentamento e come questo non sia un rimedio.