This is a creation in Article, where the information may have evolved or changed.
The most exciting part of Go for me is the design of the interface, such as static type, compile-time check. If I had to apply one of the features of go to the design of other languages, it would have to be a non-interface.
This article describes my implementation of the compiler for interface values in "GC". Ian Lance Taylor has written two articles on how interface values are implemented in GCCGO. This article is similar to the following: The biggest difference is that this article has pictures.
Usgae
The use of the Go interface makes you feel like you are using a purely dynamic language such as Python, but the difference is that the compiler still checks for static, such as passing an int variable to the Read method, or passing the wrong number of arguments to the Read method. To use the Go interface, you first need to define an interface type (such as the following Readcloser)
1 2 3 4
|
type Interface { Read (b []byteint, err os. Error) Close () }
|
Then define a function with the Readcloser parameter. For example, the following function takes all the request data by looping through the Read method, and then calls the Close method
1 2 3 4 5 6 7 8 9 10 |
func Readandclose (R readcloser, buf []byteint, err os. Error) { for Len 0 Nil { var int NR, err = R.read (BUF) n + = NR BUF = Buf[nr:] } R.close () Return }
|
When calling the Readandclose function, any type that implements the read and close methods can be passed in as the first parameter of the function. And unlike Python, if you pass a wrong type, the compiler will report a type error instead of waiting for the error to run.
Of course, interfaces are not limited to static checks, and you can also dynamically check the actual type of an interface at run time. For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
type Stringer interface { string () string }
func ToString (any interface{}) string { If V, OK: = any. (Stringer); OK { return v.string () } Switch V: = any. (type) { Case int: return StrConv. Itoa (v) Case float: return StrConv. Ftoa (V, ' G ',-1) } return "???" }
|
The type of the any parameter is interface{}, that is, there is no method for the interface, which means that it can receive any type of argument. The "comma OK" expression in the If statement attempts to convert any to the Stringer interface type, which contains the string method, and if the conversion succeeds, the string method is called to get the value of the strings and return back, within the parentheses of the IF. If the conversion fails, in the next switch statement, the two underlying types are validated. This function is like the implementation of a lite version of the FMT package. (The If statement here can actually be translated into case Stringer put to the top of Swtich, here to demonstrate deliberately written like this)
Take a simple example: Consider defining a binary type with an underlying type of unit64, and write a string () method and a Get () method for it
1 2 3 4 5 6 7 8 9 |
type UInt64
func string { return 2) }
func UInt64 { return UInt64 (i) }
|
Assuming that a value of type binary is passed to the ToString function, the string method is called and returned inside ToString. In the process of running the program, the runtime knows that the binary type has a string method, so the program thinks it implements the Stringer interface. This is a wonderful design, because sometimes even binary writers do not know the existence of the Stringer interface.
These examples show that even if all implicit conversions are checked at compile time, the interface-to-interface transformations shown can also be performed at run time. Effective Go has more detailed examples of interfaces.
Interface Values
The language that owns the method is usually divided into two major camps:
- Static languages (such as C + + and Java) that have method tables
- Each invocation has a dynamic language for method address queries (such as Smalltalk and its many imitators, JavaScript, Python, and so on, some of which increase the efficiency of its invocation through caching).
Go is in the middle of both, and it has a method table, but it evaluates them at run time. I don't know if go is the first language to use the technology, but this approach is certainly not common.
For example, the value of a binary type is a 64-bit integer, which consists of two 32-bit words (assuming that we are using a 32-bit machine)
The interface consists of two word lengths, one of which stores pointers to the interface type table and another that stores pointers to data. If B is assigned to an interface variable of type stringer, its memory structure is as shown
(The pointer stored in the interface is not visible to the program, it is not exposed directly to the user)
The pointer in the first byte points to a block of memory named Itable (called Itab in C). The Itable header stores some type-related metadata, and then the list of function pointers. The itable corresponds to an interface type, not a dynamic type. In our case, Stringer's itable preserves the metadata of the type binary, and then the list of function pointers that satisfy the Stringer interface, in this case there is only one string method, Other binary methods do not appear in the itable.
The pointer in the second byte points to a copy of B. The following assignment statement var s Stringer = B will request a copy of B and point the pointer in the second byte to that copy, with the same principle as var c unit64 = B. When we change the value of B, the values of S and C will not be changed. The value stored in the interface can be any size, but the interface itself has only one word to store the data, so the program requests a set of memory on the heap to store the data, and then points the pointer on the second word to that memory group. (If the size of the data is just equal to or less than a single word, we'll discuss that later)
Like the specific type checking performed by the switch statement above, the Go compiler generates code equivalent to S.tag->type in C to check if the actual type and expected type are the same, and if the same, then the s.data will be copied and assigned to the expected value.
When calling S. String (), the Go compiler generates the equivalent of s.tab->fun[0 in C] (S.data), which invokes the method pointed to by the function pointer and passes the second word of the interface as the first parameter. It is worth noting that it passes the value of the 32-bit pointer in the second word, not the 64-bit value that the pointer points to. In general, an interface call does not know what the byte represents and how big the data it points to. The signature of the function pointer stored in the itable is strictly respected by the method invocation, so in this case the signature of the method should be (*binary) a string instead of a (Binary) string.
There is only one method in the example, and if there are multiple methods, there will be more than one function pointer at the bottom of the itable table.
Computing the Itable
Now we know the structure of itable, but how does it come about? The dynamic type conversion feature of the go language determines that it is not possible to generate it at compile time because there will be too many pairs (interface type and concrete type), and most will not be used. Instead, the compiler generates a type description structure for each actual type that contains a list of methods implemented by that type. Similarly, the compiler generates a type description structure for each interface type, and it also contains a list of interface methods. The interface generates itable at run time by looking up the type description structure of the interface and the type description structure of the specific type, and caches the itable, so that the calculation is only executed once.
In our case, there is only one method in the type description structure of stringer, and there are two methods in binary. Assuming that the interface type has a NI method, the actual type has NT methods, then the time to find the method that it matches is O (Ni x nt), we can store them in map, then the time complexity in the lookup process becomes O (ni + nt).
Memory Optimizations
The memory usage of the interface can be optimized in both cases.
- If the interface type is interface{}, that is, there is no method, itable there is no need to exist. In this case, the first word can store the actual type:
An interface there is no way the go will be represented by a static property, so the compiler knows which case the first word represents.
- If the value of the type associated with the interface is exactly the size of a machine word, then there is no need to make a request for heap memory. If we define a Binary32 as binary, and the uint32 as its underlying type, then its value can be stored directly in the second word:
In this case, the function signature of string becomes a (Binary) string instead of the original (*binary) string.
Therefore, when an empty interface is assigned a value (or smaller) that is the size of a machine word length, it will use both of the above optimization methods.