Skip to content

Go Type System and Type Conversion

A Failure of Assignment

Recently, my friend asked me a question, why inthe following go code, b cannot be assigned to f? With knowledges about AST, I got intrigue on it than ever before.

type (
    Bar string
    Foo Bar
)

func main() {
    var f Foo
    var b Bar
    f = b
}
/main.go:17:6: cannot use b (variable of type Bar) as type Foo in assignment
func main() {
    var f Foo = "foo"
    var b Bar
}

The failure is caused by Bar and Foo are not same type, hence, they cannot be assigned to each other. However, assign a string to Bar or Foo directly is valid.

This blog talks why the conversion fails and will introduce the type, type definition, properities of types with some examples.

Assumption

Type string could assign to either Foo or Bar, but Bar can't assign to Foo. My assumption is when compiler parse AST, it checks the AST with grammar and aborts as the assignment violates the grammar because the error is raised in compiling, not runtime.

  • when parsing AST, the parser recursively traces the underlying type until a predeclared type.
  • the grammar of assigning requires satisfies either same type or underlying type.

At first, I try to figure out the anwser by reading source code directly. However, I found it's more effiecient to read the Go Language Specification as it states the esstential rules in go.

Then, the following content confirms my assumption and briefly introduce related knowledges.

Knowledges of Go Type

Type Definition in EBNF

The specification used here is on 1.18, which supports generic. The EBNF definition of type is shown below:

Type      = TypeName [ TypeArgs ] | TypeLit | "(" Type ")" .
TypeName  = identifier | QualifiedIdent .
TypeArgs  = "[" TypeList [ "," ] "]" .
TypeList  = Type { "," Type } .
TypeLit   = ArrayType | StructType | PointerType | FunctionType |
      InterfaceType | SliceType | MapType | ChannelType .
The EBNF definition reveals out formats which AST will treat it as a type. The [] refers 0 or 1 time and TypeArgs stands for some types quoted by []. It could express basic type declartion and generic type declartion.

The crucial concept for this blog is named type, denotes whether a type has a name. For example, the Example is a named type, but (int, error) isn't.

type Example struct {
    filed1 int
    field2 string
}
type Example[ T1, T2 ]{
    field1 T1
    field2 T2
}

This syntax is used in multiple returned values(e.g, (int,error) by functions. The whole part is treated as a single part. That's why you CANNOT do this because the (int, error) is a single type.

func maybe() (int, error){
    return 1,nil
}

func receiveMaybe(i int,err error) {
    // do something
}
func main() {
    // invalid function usage.
    receiveMaybe(maybe())
}

Underlying Type

Based on the go spec in topic properties of types and values.

The assumption follows the spec, there is an field called underlying type in the type. Moreover, there is a core type as well, however, as it's designed for interface, it's out of topic and won't be mentioned here.

Each type T has an underlying type: If T is one of the predeclared boolean, numeric, or string types, or a type literal, the corresponding underlying type is T itself. Otherwise, T's underlying type is the underlying type of the type to which T refers in its declaration. For a type parameter that is the underlying type of its type constraint, which is always an interface.

Type Identity

Before we talks about assignment, it's important to know type identity.

Two types are either identical or different. A named type is always different from any other type. Otherwise, two types are identical if their underlying type literals are structurally equivalent; that is, they have the same literal structure and corresponding components have identical types. (check spec to learn more detailed rules).

The reason why named type is always different from any other type is caused the representation of structure type in the implementation. Go defines a Named type to represent the named types. Hence, one named type will always be different from another named type.

// src/go/types/predicates.go#L222
func (c *comparer) identical(x, y Type, p *ifacePair) bool {
    // ignore lines
    switch x := x.(type) {
        // ignore lines

    // L436
    case *Named:
        // Two named types are identical if their type names originate
        // in the same type declaration; if they are instantiated they
        // must have identical type argument lists.
        if y, ok := y.(*Named); ok {
            // check type arguments before origins to match unifier
            // (for correct source code we need to do all checks so
            // order doesn't matter)
            xargs := x.TypeArgs().list()
            yargs := y.TypeArgs().list()
            if len(xargs) != len(yargs) {
                return false
            }
            for i, xarg := range xargs {
                if !Identical(xarg, yargs[i]) {
                    return false
                }
            }
            return indenticalOrigin(x, y)
        }
    }
}

Assignability

This topic will explain the problem, why string can but Bar cannot. There are some rules to judge the assignability of value x of type V to a variable of type T. The idea is simple, because both Foo and Bar have underlying type string, so string could assign them. However, because neither type identifiers vary between them, Foo and Bar cannot be assigned.

Alias and New Type

When defining types, we could also define an alias for one type with =.

type Foo = string
type Bar = Foo

func main(){
    var f Foo
    var b Bar
    f = b // valid assignment
    b = f // valid assignment
}
In those cases, the Foo and Bar are string essentially. Hence, they could assign to each other as they are all string type.

Why Does Assignment Fail?

The answer is named type Foo and Bar have different Identical. Hence, they are not same so they cannot be assigned to each other even though they have the same underlying type. The src/go/types/operand.go#assignableTo implements all the behaviors in the document.

How Unsigned Integer and Float64 Are Converted

The last blog about conversion precise lose between float and uint analyzes why the precise is lost. Here we take a further step to learn more about them.

First, all float number has a default float64 type unless you declare it explicitly with float32.

f64 := 3.14          // f64 is a float64 variable
f32 := float32(3,14) // f32 is a float32 variable
  • Question: does the 3.14) have a type?
  • Anwser: no, 3.14 is a float-point literal, which doesn't has a type.

Literal and Type

In computer science, a literal is a notation for representing a fixed value in source code. In contrast to literals, variables or constants are symbols that can take on one of a class of fixed values, the constant being constrained not to change. According to introduction of literal, literal is a concept in token layer, which is generated by lexer and consumed by an AST parser.

Hence, there is a mapping between literals and types. Moreover, in a language layer, we usually call constant for all types of literals. For example, go has rune, integer, floating-point, imaginary, string literal and so forth.

As a result, the type of a constant refers to the type of the literal.

Representablity of Constant

As we mentioned before, a constant has a type. However, constant allows an implicit cast, which is called representability in go. It means when we assign a variable with a constant, it's valid if the destination is representable to the constant.

A constant x is representable by a value of type T, where T is not a type parameter(generic feature case, ignore), if one of the following conditions applies:

  • X is in the set of values determined by T:

    var str string
    // "helloworld" is in the set of string values
    str = "helloworld" 
    

  • T is a floating-point type and x can be rounded to T's precision without overflow. Rounding uses IEEE 754 round-to-even rules but with an IEEE negative zero further simplified to an unsigned zero. Note that constant values never result in an IEEE negative zero, NaN, or infinity.

    // the number could be round within float64 without overflow
    var f float64 = 18446744073709551615 
    

  • T is a complex type, and x's components real(x) and imag(x) are representable by values of T's component type (float32 or float64).

    var f float32
    // f is 42, (with zero imaginary part) is in the set of float32 values
    f = (42 + 0i) 
    

See more examples in go spec representability topic.