Sprachmechanik von Stapeln und Zeigern

Auftakt


Dies ist der erste von vier Artikeln in der Reihe, die Einblicke in die Mechanik und das Design von Zeigern, Stapeln, Haufen, Escape-Analysen und Go / Pointer-Semantik bieten. In diesem Beitrag geht es um Stapel und Zeiger.

Inhaltsverzeichnis:

  1. Sprachmechanik auf Stapeln und Zeigern
  2. Sprachmechanik zur Fluchtanalyse ( Übersetzung )
  3. Sprachmechanik zur Speicherprofilerstellung
  4. Designphilosophie zu Daten und Semantik

Einführung


Ich werde nicht zerlegen - Zeiger sind schwer zu verstehen. Bei unsachgemäßer Verwendung können Zeiger unangenehme Fehler und sogar Leistungsprobleme verursachen. Dies gilt insbesondere beim Schreiben von Wettbewerbsprogrammen oder Multithread-Programmen. Es überrascht nicht, dass viele Sprachen versuchen, Zeiger vor Programmierern zu verbergen. Wenn Sie jedoch in Go schreiben, können Sie Zeigern nicht entkommen. Ohne ein klares Verständnis der Zeiger wird es für Sie schwierig sein, sauberen, einfachen und effizienten Code zu schreiben.

Rahmenränder


Funktionen werden innerhalb der Grenzen von Frames ausgeführt, die für jede entsprechende Funktion einen separaten Speicherplatz bereitstellen. Jeder Frame ermöglicht es der Funktion, in ihrem eigenen Kontext zu arbeiten, und bietet auch eine Flusskontrolle. Eine Funktion hat über einen Zeiger direkten Zugriff auf den Speicher innerhalb ihres Frames, der Zugriff auf den Speicher außerhalb des Frames erfordert jedoch einen indirekten Zugriff. Damit eine Funktion außerhalb ihres Frames auf Speicher zugreifen kann, muss dieser Speicher in Verbindung mit dieser Funktion verwendet werden. Die durch diese Grenzen festgelegten Mechanismen und Einschränkungen müssen zuerst verstanden und untersucht werden.

Wenn eine Funktion aufgerufen wird, tritt ein Übergang zwischen zwei Rahmen auf. Der Code geht vom Frame der aufrufenden Funktion zum Frame der aufgerufenen Funktion. Wenn die Daten zum Aufrufen der Funktion benötigt werden, müssen diese Daten von einem Frame zu einem anderen übertragen werden. Die Übertragung von Daten zwischen zwei Frames in Go erfolgt "nach Wert".

Der Vorteil der Datenübertragung nach Wert ist die Lesbarkeit. Der Wert, den Sie im Funktionsaufruf sehen, wird auf der anderen Seite kopiert und akzeptiert. Deshalb verbinde ich mit WYSIWYG „Pass by Value“, denn was Sie sehen, ist das, was Sie bekommen. All dies ermöglicht es Ihnen, Code zu schreiben, der die Kosten für das Umschalten zwischen zwei Funktionen nicht verbirgt. Dies hilft dabei, ein gutes mentales Modell dafür zu erhalten, wie sich jeder Funktionsaufruf während des Übergangs auf das Programm auswirkt.

Schauen Sie sich dieses kleine Programm an, das eine Funktion aufruft, indem es ganzzahlige Daten "nach Wert" übergibt:

Listing 1:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
10
11    // Pass the "value of" the count.
12    increment(count)
13
14    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc int) {
19
20    // Increment the "value of" inc.
21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
23 }

Wenn Ihr Go-Programm gestartet wird, erstellt die Laufzeit die Haupt-Goroutine, um die Ausführung des gesamten Codes einschließlich des Codes in der Hauptfunktion zu starten. Gorutin ist der Ausführungspfad, der in den Thread des Betriebssystems passt, der letztendlich auf einem Kernel ausgeführt wird. Ab Version 1.8 verfügt jede Goroutine über einen ersten Block eines kontinuierlichen Speichers mit einer Größe von 2048 Byte, der den Stapelspeicher bildet. Diese anfängliche Stapelgröße hat sich im Laufe der Jahre geändert und kann sich in Zukunft ändern.

Der Stapel ist wichtig, da er physischen Speicherplatz für die Rahmengrenzen bereitstellt, die jeder einzelnen Funktion zugewiesen werden. Wenn die Hauptgoroutine die Hauptfunktion in Listing 1 ausführt, sieht der Programmstapel (auf einer sehr hohen Ebene) folgendermaßen aus:

Abbildung 1:



In Abbildung 1 sehen Sie, dass ein Teil des Stapels für die Hauptfunktion „gerahmt“ wurde. Dieser Abschnitt wird als " Stapelrahmen " bezeichnet, und dieser Rahmen bezeichnet die Grenze der Hauptfunktion auf dem Stapel. Der Frame wird als Teil des Codes festgelegt, der beim Aufruf der Funktion ausgeführt wird. Sie können auch sehen, dass der Speicher für die Zählvariable bei 0x10429fa4 innerhalb des Frames für main zugewiesen wurde.

Es gibt einen weiteren interessanten Punkt, der in Abbildung 1 dargestellt ist. Der gesamte Stapelspeicher unter dem aktiven Frame ist ungültig, aber der Speicher aus dem aktiven Frame und höher ist gültig. Sie müssen die Grenze zwischen dem gültigen und dem ungültigen Teil des Stapels klar verstehen.

Adressen


Variablen werden verwendet, um einer bestimmten Speicherzelle einen Namen zuzuweisen, um die Lesbarkeit des Codes zu verbessern und um zu verstehen, mit welchen Daten Sie arbeiten. Wenn Sie eine Variable haben, haben Sie einen Wert im Speicher, und wenn Sie einen Wert im Speicher haben, muss diese eine Adresse haben. In Zeile 09 ruft die Hauptfunktion die integrierte Druckfunktion auf, um den "Wert" und die "Adresse" der Zählvariablen anzuzeigen.

Listing 2:

09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")

Die Verwendung des kaufmännischen Und "&" zum Abrufen der Adresse des Speicherorts einer Variablen ist nicht neu. Andere Sprachen verwenden diesen Operator ebenfalls. Die Ausgabe von Zeile 09 sollte wie die folgende aussehen, wenn Sie Code auf einer 32-Bit-Architektur wie Go Playground ausführen:

Listing 3:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

Funktionsaufruf


Als nächstes ruft die Hauptfunktion in Zeile 12 die Inkrementfunktion auf.

Listing 4:

12    increment(count)

Ein Funktionsaufruf bedeutet, dass das Programm einen neuen Speicherabschnitt auf dem Stapel erstellen muss. Alles ist jedoch etwas komplizierter. Um einen Funktionsaufruf erfolgreich abzuschließen, wird erwartet, dass Daten über die Rahmengrenze übertragen und während des Übergangs in einem neuen Rahmen platziert werden. Insbesondere wird erwartet, dass ein ganzzahliger Wert während des Aufrufs kopiert und übertragen wird. Sie können diese Anforderung anhand der Deklaration der Inkrementfunktion in Zeile 18 erkennen.

Listing 5:

18 func increment(inc int) {

Wenn Sie sich den Aufruf der Inkrementfunktion in Zeile 12 noch einmal ansehen, werden Sie feststellen, dass der Code den „Wert“ der Variablenanzahl überschreitet. Dieser Wert wird kopiert, übertragen und für die Inkrementierungsfunktion in einen neuen Frame eingefügt. Denken Sie daran, dass die Inkrementierungsfunktion nur in einem eigenen Frame lesen und in den Speicher schreiben kann. Daher benötigt sie die Variable inc, um eine eigene Kopie des übertragenen Zählerwerts abzurufen, zu speichern und darauf zuzugreifen.

Kurz bevor der Code in der Inkrementfunktion ausgeführt wird, sieht der Programmstapel (auf einer sehr hohen Ebene) folgendermaßen aus:

Abbildung 2:



Sie können sehen, dass sich jetzt zwei Frames auf dem Stapel befinden - einer für Main und einer unten für Inkremente. Innerhalb des Rahmens für das Inkrement sehen Sie die inc-Variable mit dem Wert 10, die während des Funktionsaufrufs kopiert und übergeben wurde. Die Adresse der inc-Variablen lautet 0x10429f98 und ist weniger im Speicher, da die Frames auf den Stapel verschoben werden. Dies sind nur Implementierungsdetails, die nichts bedeuten. Wichtig ist, dass das Programm den Zählwert aus dem Frame für main abgerufen und eine Kopie dieses Werts in den Frame eingefügt hat, um ihn mithilfe der inc-Variablen zu erhöhen.

Der Rest des Codes innerhalb des Inkrements erhöht und zeigt den "Wert" und die "Adresse" der inc-Variablen an.

Listing 6:

21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")

Die Ausgabe von Zeile 22 auf dem Spielplatz sollte ungefähr so ​​aussehen:

Listing 7:

inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]

So sieht der Stapel nach dem Ausführen derselben Codezeilen aus:

Abbildung 3:



Nach dem Ausführen der Zeilen 21 und 22 endet die Inkrementierungsfunktion und gibt die Steuerung an die Hauptfunktion zurück. Dann zeigt die Hauptfunktion erneut den „Wert“ und die „Adresse“ der lokalen Variablenanzahl in Zeile 14 an.

Listing 8:

14    println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")

Die vollständige Ausgabe des Programms auf dem Spielplatz sollte ungefähr so ​​aussehen:

Listing 9:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]
count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

Der Zählwert im Frame für main ist vor und nach dem Aufruf des Inkrements derselbe.

Rückkehr von Funktionen


Was passiert eigentlich mit dem Speicher auf dem Stapel, wenn die Funktion beendet wird und die Steuerung zur aufrufenden Funktion zurückkehrt? Die kurze Antwort ist nichts. So sieht der Stapel aus, nachdem die Inkrementfunktion zurückgegeben wurde:

Abbildung 4:



Der Stapel sieht genauso aus wie in Abbildung 3, außer dass der der Inkrementfunktion zugeordnete Frame jetzt als ungültiger Speicher betrachtet wird. Dies liegt daran, dass der Frame für main jetzt aktiv ist. Der für die Inkrementierungsfunktion erstellte Speicher blieb unberührt.

Das Löschen des Speicherrahmens der Rückgabefunktion ist Zeitverschwendung, da nicht bekannt ist, ob dieser Speicher jemals wieder benötigt wird. So blieb die Erinnerung so wie sie war. Wenn bei jedem Funktionsaufruf ein Frame genommen wird, wird der Stapelspeicher für diesen Frame gelöscht. Dies erfolgt durch Initialisieren aller Werte, die in den Frame passen. Da alle Werte als "Nullwert" initialisiert werden, werden die Stapel bei jedem Funktionsaufruf korrekt gelöscht.

Wertteilung


Was wäre, wenn es wichtig wäre, dass die Inkrementfunktion direkt mit der Zählvariablen arbeitet, die im Frame für main vorhanden ist? Hier kommt die Zeit für Zeiger. Zeiger dienen einem Zweck - einen Wert mit einer Funktion zu teilen, damit die Funktion diesen Wert lesen und schreiben kann, auch wenn der Wert nicht direkt in ihrem Frame vorhanden ist.

Wenn Sie nicht glauben, dass Sie den Wert "teilen" müssen, müssen Sie keinen Zeiger verwenden. Wenn Sie Zeiger lernen, ist es wichtig zu berücksichtigen, dass Sie ein sauberes Wörterbuch verwenden, keine Operatoren oder Syntax. Denken Sie daran, dass Zeiger gemeinsam genutzt werden sollen. Ersetzen Sie beim Lesen von Code den Operator & durch den Ausdruck „Freigabe“.

Arten von Zeigern


Für jeden Typ, den Sie deklariert haben oder der direkt von der Sprache selbst deklariert wurde, erhalten Sie einen kostenlosen Zeigertyp, den Sie für die Freigabe verwenden können. Es gibt bereits einen integrierten Typ namens int, daher gibt es einen Zeigertyp namens * int. Wenn Sie einen Typ mit dem Namen Benutzer deklarieren, erhalten Sie kostenlos einen Zeigertyp mit dem Namen * Benutzer.

Alle Arten von Zeigern haben zwei identische Eigenschaften. Zunächst beginnen sie mit dem Zeichen *. Zweitens haben sie alle die gleiche Größe im Speicher und eine Darstellung, die 4 oder 8 Bytes belegt, die die Adresse darstellen. Bei 32-Bit-Architekturen (z. B. auf dem Spielplatz) benötigen Zeiger 4 Byte Speicher und bei 64-Bit-Architekturen (z. B. Ihrem Computer) 8 Byte Speicher.

In der Spezifikation Zeigertypenwerden als Typliterale betrachtet , dh es handelt sich um namenlose Typen, die aus einem vorhandenen Typ bestehen.

Indirekter Speicherzugriff


Schauen Sie sich dieses kleine Programm an, das einen Funktionsaufruf ausführt und die Adresse "nach Wert" übergibt. Dadurch wird die Zählvariable mit der Inkrementfunktion vom Stapelrahmen von main getrennt:

Listing 10:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
10
11    // Pass the "address of" count.
12    increment(&count)
13
14    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc *int) {
19
20    // Increment the "value of" count that the "pointer points to". (dereferencing)
21    *inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
23 }

Am ursprünglichen Programm wurden drei interessante Änderungen vorgenommen. Die erste Änderung befindet sich in Zeile 12:

Listing 11:

12    increment(&count)

Dieses Mal kopiert der Code in Zeile 12 nicht und übergibt den "Wert" an die Zählvariable, sondern übergibt seine "Adresse" anstelle der Zählvariablen. Jetzt können Sie sagen: "Ich teile" die Anzahl der Variablen mit dem Funktionsinkrement. Dies ist, was der & Betreiber sagt - "Teilen".

Verstehen Sie, dass dies immer noch "Wert übergeben" ist und der einzige Unterschied darin besteht, dass der Wert, den Sie übergeben, die Adresse und nicht die Ganzzahl ist. Adressen sind auch Werte; Dies wird kopiert und über den Rahmenrand übertragen, um die Funktion aufzurufen.

Da der Adresswert kopiert und übergeben wird, benötigen Sie eine Variable innerhalb des Inkrementrahmens, um diese ganzzahlige Adresse abzurufen und zu speichern. Eine Ganzzahlzeigervariablendeklaration befindet sich in Zeile 18.

Listing 12:

18 func increment(inc *int) {

Wenn Sie die Adresse des Werts vom Typ Benutzer übergeben haben, muss die Variable als * Benutzer deklariert werden. Trotz der Tatsache, dass alle Zeigervariablen Adresswerte speichern, können ihnen keine Adressen übergeben werden, sondern nur Adressen, die dem Zeigertyp zugeordnet sind. Das Grundprinzip beim Teilen eines Werts besteht darin, dass die Empfangsfunktion diesen Wert lesen oder darauf schreiben muss. Sie benötigen Informationen über den Typ eines Werts, um ihn lesen und schreiben zu können. Der Compiler stellt sicher, dass mit dieser Funktion nur Werte verwendet werden, die dem richtigen Zeigertyp zugeordnet sind.

So sieht der Stapel nach dem Aufrufen der Inkrementfunktion aus:

Abbildung 5:



Abbildung 5 zeigt, wie der Stapel aussieht, wenn "Wert übergeben" unter Verwendung der Adresse als Wert ausgeführt wird. Die Zeigervariable innerhalb des Rahmens für die Inkrementierungsfunktion zeigt jetzt auf die Zählvariable, die sich innerhalb des Rahmens für main befindet.

Mithilfe der Zeigervariable kann die Funktion nun eine indirekte Lese- und Änderungsoperation für die Zählvariable ausführen, die sich im Rahmen für main befindet.

Listing 13:

21    *inc++

Dieses Mal fungiert das Zeichen * als Operator und wird auf die Zeigervariable angewendet. Die Verwendung von * als Operator bedeutet "den Wert, auf den der Zeiger zeigt". Eine Zeigervariable bietet indirekten Zugriff auf den Speicher außerhalb des Rahmens der Funktion, die ihn verwendet. Manchmal wird dieses indirekte Lesen oder Schreiben als Zeiger-Dereferenzierung bezeichnet. Die Inkrementfunktion muss noch eine Zeigervariable in ihrem Frame haben, die sie direkt lesen kann, um einen indirekten Zugriff durchzuführen.

Abbildung 6 zeigt, wie der Stapel nach Zeile 21 aussieht.

Abbildung 6:



Hier ist die endgültige Ausgabe dieses Programms:

Listing 14:

count:  Value Of[ 10 ]              Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 0x10429fa4 ]      Addr Of[ 0x10429f98 ]   Value Points To[ 11 ]
count:  Value Of[ 11 ]              Addr Of[ 0x10429fa4 ]

Möglicherweise stellen Sie fest, dass der „Wert“ der inc-Zeigervariablen mit der „Adresse“ der Zählvariablen übereinstimmt. Dadurch wird eine Freigabebeziehung hergestellt, die einen indirekten Zugriff auf den Speicher außerhalb des Frames ermöglicht. Sobald die Inkrementfunktion den Zeiger durchschreibt, ist die Änderung für die Hauptfunktion sichtbar, wenn die Steuerung an sie zurückgegeben wird.

Zeigervariablen sind nichts Besonderes


Zeigervariablen sind nichts Besonderes, da sie dieselben Variablen wie jede andere Variable sind. Sie haben eine Speicherzuordnung und enthalten Bedeutung. Es ist einfach so passiert, dass alle Zeigervariablen, unabhängig von der Art des Werts, auf den sie zeigen können, immer dieselbe Größe und Darstellung haben. Was verwirrend sein kann, ist, dass das Zeichen * als Operator innerhalb des Codes fungiert und zum Deklarieren eines Zeigertyps verwendet wird. Wenn Sie eine Typdeklaration von einer Zeigeroperation unterscheiden können, kann dies dazu beitragen, Verwirrung zu vermeiden.

Fazit


Dieser Beitrag beschreibt den Zweck von Zeigern, die Funktionsweise des Stapels und die Mechanik von Zeigern in Go. Dies ist der erste Schritt zum Verständnis der Mechanik, Konstruktionsprinzipien und Verwendungstechniken, die zum Schreiben von kohärentem und lesbarem Code erforderlich sind.

Am Ende haben Sie Folgendes gelernt:

  • Funktionen werden innerhalb der Rahmengrenzen ausgeführt, die für jede entsprechende Funktion einen separaten Speicherplatz bereitstellen.
  • Wenn eine Funktion aufgerufen wird, tritt ein Übergang zwischen zwei Rahmen auf.
  • Der Vorteil der Datenübertragung nach Wert ist die Lesbarkeit.
  • Der Stapel ist wichtig, da er physischen Speicherplatz für die Rahmengrenzen bereitstellt, die jeder einzelnen Funktion zugewiesen werden.
  • Der gesamte Stapelspeicher unter dem aktiven Frame ist ungültig, aber der Speicher ab dem aktiven Frame und darüber ist gültig.
  • , .
  • , , .
  • — , , .
  • , , , , .
  • - , .
  • - - , , . , .

All Articles