web-dev-qa-db-fra.com

Pourquoi les programmes utilisent-ils des piles d'appels, si les appels de fonction imbriqués peuvent être intégrés?

Pourquoi ne pas demander au compilateur de prendre un programme comme celui-ci:

function a(b) { return b^2 };
function c(b) { return a(b) + 5 };

et le convertir en un programme comme celui-ci:

function c(b) { return b^2 + 5 };

éliminant ainsi le besoin de l'ordinateur de se souvenir de l'adresse de retour de c (b)?

Je suppose que l'augmentation de l'espace sur le disque dur et RAM nécessaire pour stocker le programme et prendre en charge sa compilation (respectivement) est la raison pour laquelle nous utilisons des piles d'appels. Est-ce correct?

33
moonman239

Ceci est appelé "inline" et de nombreux compilateurs le font comme stratégie d'optimisation dans les cas où cela a du sens.

Dans votre exemple particulier, cette optimisation permettrait d'économiser de l'espace et du temps d'exécution. Mais si la fonction était appelée à plusieurs endroits du programme (ce qui n'est pas rare!), Cela augmenterait la taille du code, de sorte que la stratégie deviendrait plus douteuse. (Et bien sûr, si une fonction s'appelait directement ou indirectement, il serait impossible de l'intégrer, car alors le code deviendrait infini.)

Et évidemment, cela n'est possible que pour les fonctions "privées". Les fonctions qui sont exposées pour les appelants externes ne peuvent pas être optimisées, du moins pas dans les langues avec liaison dynamique.

75
JacquesB

Votre question comporte deux parties: pourquoi avoir plusieurs fonctions (au lieu de remplacer les appels de fonction par leur définition) et pourquoi implémenter ces fonctions avec des piles d'appels au lieu d'allouer statiquement leurs données ailleurs?

La première raison est la récursivité. Non seulement le type "oh, faisons un nouvel appel de fonction pour chaque élément de cette liste", mais aussi le type modeste où vous avez peut-être deux appels d'une fonction active en même temps, avec de nombreuses autres fonctions entre eux. Vous devez placer les variables locales sur une pile pour prendre en charge cela, et vous ne pouvez pas intégrer les fonctions récursives en général.

Ensuite, il y a un problème pour les bibliothèques: vous ne savez pas quelles fonctions seront appelées d'où et à quelle fréquence, de sorte qu'une "bibliothèque" ne pourrait jamais vraiment être compilée, uniquement livrée à tous les clients dans un format de haut niveau pratique qui sera ensuite intégré dans l'application. Mis à part d'autres problèmes avec cela, vous perdez complètement la liaison dynamique avec tous ses avantages.

De plus, il existe de nombreuses raisons de ne pas incorporer de fonctions, même lorsque vous le pouvez:

  1. Ce n'est pas nécessairement plus rapide. Configurer le cadre de pile et le démonter sont peut-être une douzaine d'instructions à cycle unique, pour de nombreuses fonctions volumineuses ou en boucle qui ne représentent même pas 0,1% de leur temps d'exécution.
  2. Cela peut être plus lent. La duplication de code a des coûts, par exemple, cela mettra plus de pression dans le cache d'instructions.
  3. Certaines fonctions sont très volumineuses et appelées depuis de nombreux endroits, les aligner partout augmente le binaire bien au-delà de ce qui est raisonnable.
  4. Les compilateurs ont souvent du mal avec de très grandes fonctions. Toutes choses étant égales par ailleurs, une fonction de taille 2 * N prend plus de 2 * temps T où une fonction de taille N prend temps T.
51
user7043

Les piles nous permettent de contourner avec élégance les limites imposées par le nombre fini de registres.

Imaginez avoir exactement 26 registres mondiaux "a-z" (ou même ne disposer que des 7 registres de la puce 8080) et chaque fonction que vous écrivez dans cette application partage cette liste plate.

Un début naïf serait d'allouer les premiers registres à la première fonction, et sachant qu'il n'en fallait que 3, commencez par "d" pour la deuxième fonction ... Vous vous épuisez rapidement.

Au lieu de cela, si vous avez une bande métaphorique, comme la machine de turing, vous pouvez demander à chaque fonction de démarrer un "appel d'une autre fonction" en enregistrant toutes les variables c'est en utilisant et en avant () la bande, et alors la fonction appelée peut s'embrouiller avec autant de registres qu'elle le souhaite. Lorsque l'appelé est terminé, il renvoie le contrôle à la fonction parent, qui sait où accrocher la sortie de l'appelé selon les besoins, puis lit la bande en arrière pour restaurer son état.

Votre trame d'appel de base est juste cela, et est créée et supprimée par des séquences de code machine standardisées que le compilateur met autour des transitions d'une fonction à l'autre. (Cela fait longtemps que je n'ai pas dû me souvenir de mes cadres de pile C, mais vous pouvez lire sur les différentes façons dont les devoirs de qui laisse tomber quoi à X86_calling_conventions.)

(la récursivité est géniale, mais si vous aviez jamais dû jongler avec des registres sans pile, alors vous auriez vraiment apprécier les piles.)


Je suppose que l'espace disque accru et RAM nécessaire pour stocker le programme et prendre en charge sa compilation (respectivement) est la raison pour laquelle nous utilisons des piles d'appels. Est-ce correct?

Bien que nous puissions en aligner davantage de nos jours, ("plus de vitesse" est toujours bon; "moins de ko d'assemblage" signifie très peu dans un monde de flux vidéo) La principale limitation réside dans la capacité du compilateur à s'aplatir sur certains types de modèles de code.

Par exemple, les objets polymorphes - si vous ne connaissez pas le seul et unique type d'objet qui vous sera remis, vous ne pouvez pas l'aplatir; vous devez regarder la table des fonctionnalités de l'objet et appeler à travers ce pointeur ... trivial à faire au moment de l'exécution, impossible à aligner au moment de la compilation.

Une chaîne d'outils moderne peut heureusement aligner une fonction définie par polymorphisme lorsqu'elle a suffisamment aplati le ou les appelants pour savoir exactement de quelle saveur obj est:

class Base {
    public: void act() = 0;
};
class Child1: public Base {
    public: void act() {};
};
void ActOn(Base* something) {
    something->act();
}
void InlineMe() {
    Child1 thingamabob;
    ActOn(&thingamabob);
}

dans ce qui précède, le compilateur peut choisir de garder le droit sur l'incrustation statique, d'InlineMe par le biais de quoi que ce soit à l'intérieur de act (), ni de toucher à des vtables lors de l'exécution.

Mais toute incertitude sur la saveur de l'objet le laissera comme un appel à une fonction discrète, même si d'autres invocations de la même fonction sont en ligne.

16
xander

Cas que cette approche ne peut pas traiter:

function fib(a) { if(a>2) return fib(a-1)+fib(a-2); else return 1; }

function many(a) { for(i = 1 to a) { b(i); };}

Il existe langues et plates-formes avec des piles d'appels limitées ou inexistantes. Les microprocesseurs PIC ont une pile matérielle limitée à entre 2 et 32 ​​entrées . Cela crée des contraintes de conception.

COBOL interdit la récursivité: https://stackoverflow.com/questions/27806812/in-cobol-is-it-possible-to-recursively-call-a-paragraph

Imposer une interdiction de récursivité signifie que vous pouvez représenter statiquement le graphique complet du programme en tant que DAG. Votre compilateur pourrait alors émettre une copie d'une fonction pour chaque endroit à partir duquel elle est appelée avec un saut fixe au lieu d'un retour. Aucune pile requise, juste plus d'espace de programme, potentiellement beaucoup pour les systèmes complexes. Mais pour les petits systèmes embarqués, cela signifie que vous pouvez garantir de ne pas avoir de débordement de pile lors de l'exécution, ce qui serait une mauvaise nouvelle pour votre réacteur nucléaire/turbine à réaction/contrôle des gaz de la voiture, etc.

11
pjc50

Vous voulez la fonction inlining , et la plupart des compilateurs ( optimisation ) le font.

Notez que l'inline nécessite que la fonction appelée soit connue (et n'est efficace que si cette fonction appelée n'est pas trop grande), car conceptuellement, elle remplace l'appel par la réécriture de la fonction appelée. Donc, vous ne pouvez généralement pas incorporer une fonction inconnue (par exemple un pointeur de fonction -et qui inclut des fonctions de lié dynamiquementbibliothèques partagées -, qui est peut-être visible comme méthode virtuelle dans certains - vtable ; mais certains compilateurs peuvent parfois optimiser à travers dévirtualisation techniques). Bien sûr, il n'est pas toujours possible d'inline des fonctions récursives (certains compilateurs intelligents peuvent utiliser évaluation partielle et dans certains les cas peuvent incorporer des fonctions récursives).

Notez également que l'inline, même quand c'est facilement possible, n'est pas toujours efficace: vous (en fait votre compilateur) pourriez augmenter tellement la taille du code que caches CP (ou prédicteur de branche =) fonctionnerait moins efficacement, ce qui ralentirait l'exécution de votre programme.

Je me concentre un peu sur le style programmation fonctionnelle , puisque vous avez marqué votre question comme telle.

Notez que vous n'avez pas besoin d'en avoir pile d'appels (au moins dans le sens machine de la "pile d'appels" expression). Vous ne pouvez utiliser que le tas.

Alors, jetez un oeil à continuations et en savoir plus sur style de passage de continuation (CPS) et transformation CPS (intuitivement, vous pouvez utiliser la continuation fermetures comme "cadres d'appel" réifiés alloués dans le tas, et ils imitent en quelque sorte une pile d'appels; alors vous avez besoin d'un efficace garbage collector ).

Andrew Appel a écrit un livre Compilation avec des continuations et un vieux papier la collecte des ordures peut être plus rapide que l'allocation de pile . Voir aussi l'article de A.Kennedy (ICFP2007) Compilation avec continuations, suite

Je recommande également de lire le livre de Queinnec LISP In Small Pieces , qui comporte plusieurs chapitres liés à la poursuite et à la compilation.

Notez également que certaines langues (par exemple Brainfuck ) ou des machines abstraites (par exemple OISC , RAM ) n'a pas de fonction d'appel mais reste Turing-complete , donc vous n'avez (en théorie) même pas besoin de mécanisme d'appel de fonction, même s'il est extrêmement pratique. BTW, certaines anciennes architectures jeu d'instructions (par exemple IBM/37 ) n'ont même pas de pile d'appels matériels, ni d'instructions machine d'appel poussé (IBM/370 n'avait que a Branch and Link instruction machine)

Enfin, si votre programme entier (y compris toutes les bibliothèques nécessaires) n'a pas de récursivité, vous pouvez stocker l'adresse de retour (et les variables "locales", qui deviennent en fait statiques) de chaque fonction dans des emplacements statiques. Vieux Fortran77 les compilateurs l'ont fait au début des années 80 (donc les programmes compilés n'utilisaient aucune pile d'appels à cette époque).

8

L'inline (remplacement des appels de fonction par des fonctionnalités équivalentes) fonctionne bien comme stratégie d'optimisation pour les petites fonctions simples. Les frais généraux d'un appel de fonction peuvent être efficacement échangés contre une petite pénalité dans la taille du programme supplémentaire (ou dans certains cas, aucune pénalité du tout).

Cependant, de grandes fonctions qui à leur tour appellent d'autres fonctions pourraient conduire à une énorme explosion de la taille du programme si tout était en ligne.

L'intérêt des fonctions appelables est de faciliter une réutilisation efficace, non seulement par le programmeur, mais par la machine elle-même, et cela inclut des propriétés comme une mémoire raisonnable ou une empreinte sur le disque.

Pour ce que ça vaut: vous pouvez avoir des fonctions appelables sans pile d'appel. Par exemple: IBM System/360. Lors de la programmation dans des langages tels que FORTRAN sur ce matériel, le compteur de programme (adresse de retour) serait enregistré dans une petite section de mémoire réservée juste avant le point d'entrée de la fonction. Il permet des fonctions réutilisables, mais ne permet pas la récursivité ou le code multithread (une tentative d'appel récursif ou rentrant entraînerait l'écrasement d'une adresse de retour précédemment enregistrée).

Comme expliqué par d'autres réponses, les piles sont de bonnes choses. Ils facilitent la récursivité et les appels multithreads. Bien que tout algorithme codé pour utiliser la récursivité puisse être codé sans s'appuyer sur la récursivité, le résultat peut être plus complexe, plus difficile à maintenir et peut être moins efficace. Je ne suis pas sûr qu'une architecture sans pile puisse prendre en charge le multi-threading.

8
Zenilogix