Skip to content
academia.sh

Lesson 03 / 18

Variables and Constants

Short declaration decides a variable's type without ever writing it into the source; only two of a six-constant iota sequence have a value written in the source — the other four come from repeating the previous expression — and the same short-declaration line is rejected at package level while it is accepted inside a function.

Published

In this lesson

  • Measures how short declaration determines a value's type without ever writing it in the source
  • Counts how many constants in an iota sequence have a value written in the source
  • Runs proof of why the same short-declaration line is rejected at package level
Contents

The previous lesson measured which parts are mandatory for a source to run, but throughout func main() {} the body stayed empty — no question was ever asked about what was or was not written into a value. This lesson steps into that gap. Do you have to write a variable’s type when defining it, where does short declaration infer the type it does not write, and who fills in the in-between values of a sequential constant list?

The Programming Foundations course (M01/K02) built variable definition and binding using Python; there, binding a name to a value happened with a single piece of syntax and the type never appeared in the source at all. That concept is not rebuilt here — one sentence is enough to state the difference: in Go a name has a type, and that type is either written or inferred from a value; no variable is ever left typeless. What this lesson asks is where that inference comes from.

Where Short Declaration Infers the Type From

A variable is defined in Go in two forms: var name type = value, or short declaration, name := value. In the second, the type is never written at all; the compiler determines the variable’s type by looking at the type of the assigned value itself. This is one of the examples of “an unwritten thing having a defined counterpart” — the type was not written, but a variable having no type is not an available option; the compiler infers that type from the value.

We can ask what type the inference lands on at runtime using the %T format verb; this verb prints the name of a value’s static type, and that name is fixed at compile time — it gives the same answer on every run.

// types.go — type inferred by short declaration
package main

import "fmt"

func main() {
	integer := 42
	floatingPoint := 3.14
	str := "text"
	boolean := true

	fmt.Printf("%-14s %-8s value=%v\n", "integer", fmt.Sprintf("%T", integer), integer)
	fmt.Printf("%-14s %-8s value=%v\n", "floatingPoint", fmt.Sprintf("%T", floatingPoint), floatingPoint)
	fmt.Printf("%-14s %-8s value=%v\n", "str", fmt.Sprintf("%T", str), str)
	fmt.Printf("%-14s %-8s value=%v\n", "boolean", fmt.Sprintf("%T", boolean), boolean)

	var wideType float64 = 42
	fmt.Printf("%-14s %-8s value=%v\n", "wideType", fmt.Sprintf("%T", wideType), wideType)
}
integer        int      value=42
floatingPoint  float64  value=3.14
str            string   value=text
boolean        bool     value=true
wideType       float64  value=42

LB7. %T is a static name read from the source itself; it carries no machine-, version-, or run-dependent information at all. This verb is what will be used throughout this lesson and the rest of the course to read a type’s name, because it never violates the ban on environment-dependent data.

This shows a distinction that matters for the measurement: what %T prints is not a memory address or the variable itself, it is the name of the type information the compiler assigns to that variable. Running the same source in two different runs, %T always produces the same string, just like compiles in the previous lesson always produced the same compiled/not compiled answer. Every measurement tool in this course shares these two properties: being deterministic and reading nothing that depends on the environment.

The first four lines show short declaration landing on four separate types from four separate values: writing an integer gives int, writing a decimal gives float64, writing something in quotes gives string, writing true/false gives bool. In none of them does the type name appear in the source; the := sign on the left of short declaration is shorthand for “infer this name’s type from the value on the right.”

This inference works by looking at the assigned value’s written form, not at some runtime test. When 42 is written, the compiler sees while reading the source that this is an integer constant; this decision is made without producing any value, without ever running the program. This shows that short declaration is also a compile-time decision — only the type’s name is not written in the source, the decision itself is still made during compilation, before runtime. Short declaration leaves nothing at all to runtime; what is unwritten is only the visible sequence of letters, not the decision.

How a Type Written With var Diverges

The fifth line takes a different path. If 42 had been written with short declaration it would have been int — the first line already showed that. Here the same 42 is made explicitly float64 by the line var wideType float64 = 42. Where short declaration infers the type from the value, var opens a path to setting the type by hand; the difference between the two is who decides. In short declaration, the value decides; in var, the writer decides.

This should not be confused with the implicit conversion rejection measured in the previous lesson. var wideType float64 = 42 is not a conversion, it is an initial value assignment — 42 here is an untyped constant, and untyped constants take shape based on the target type. A conversion comes into play when a variable whose type is already fixed is assigned to another variable (var a int32 = 1; var b int64 = a). That difference will be measured again later in the course; what needs to be noted here is that suppressing short declaration’s result with var is not a rejection, it is a preference.

var syntax has one more advantage: it can be used without an initial value. Writing var count int defines count without giving it any value at all, and the compiler does not reject this line — unlike short declaration, var does not demand a value. In this case the value the variable takes is the zero value, the topic of a later lesson in this course; here it is only worth noting that var allowing a type to be written and var working without a value written come from the same root — both, unlike short declaration, do not depend on the requirement of “inference from a value.”

Iota: Who Fills In the Unwritten Constant Values

Constants are defined with const, and when a group of them is listed one after another, a special counter named iota can be used. iota starts at zero and increases by one on every line of a const block; if a line has no expression of its own, the line repeats the previous line’s expression as is, and only the value of iota is updated.

This solves a real problem: numbering a set of states by hand (Pending = 0, Running = 1, Completed = 2, …) is both repetitive writing and requires every number to be shifted by hand whenever a new state is inserted in between. iota ties this numbering to the line’s order; when a line is inserted in between, every value after it shifts automatically, none of them is updated by hand. This is a mechanism built for state sets where the numbers themselves do not matter, only their order and uniqueness matter.

// constants.go — six constants with iota, how many values are written in the source
package main

import "fmt"

const (
	Pending  = iota
	Running
	Completed
	Failed  = iota * 100
	Cancelled
	TimedOut
)

func main() {
	fmt.Printf("%-12s %d\n", "Pending", Pending)
	fmt.Printf("%-12s %d\n", "Running", Running)
	fmt.Printf("%-12s %d\n", "Completed", Completed)
	fmt.Printf("%-12s %d\n", "Failed", Failed)
	fmt.Printf("%-12s %d\n", "Cancelled", Cancelled)
	fmt.Printf("%-12s %d\n", "TimedOut", TimedOut)
}
Pending      0
Running      1
Completed    2
Failed       300
Cancelled    400
TimedOut     500

LB8. Only two of the six constants (Pending, Failed) have an expression written in the source; in four of them (Running, Completed, Cancelled, TimedOut) only a name sits on the line, no value is ever written. The value of these four comes from two sources: the iota value at that line’s own position, and the expression repeated from the previous line.

The table confirms this. Running and Completed repeat Pending’s expression (iota), but are computed with the iota value of their own line (1 and 2 respectively) — that is why they climb 0, 1, 2. Cancelled and TimedOut follow the same logic, repeating Failed’s expression (iota * 100), but multiplied by their own iota values (4 and 5) — climbing 300, 400, 500. iota is a single counter across the whole block; it does not reset when a second expression appears in the group, it keeps increasing without a break, following line order.

This gives the course’s most indirect answer yet to the question “what stands in for the unwritten.” Nothing being written on a line does not mean that line gets a random or empty value; two rules together — the next iota value and the repetition of the previous expression — produce a fully defined result. All four unwritten values fall, for this reason, into the defined class, not open: the result does not change from run to run, and a reader of the source can reach the same numbers on paper too.

Since every constant here is used in a fmt.Printf call, we do not run into the unused variable rejection; the rule measured in the first lesson still holds here — an unused rejection never applies when a constant is defined at package level, because Go applies that rejection only to local variables. A constant or variable defined at package level does not stop compilation even if it is never read. This distinction will come up again in the next section.

This single rule narrows the first lesson’s rejection without contradicting it at all. The “unused variable” rejection measured in the first lesson was the most commonly encountered of the four rejections, and the question of which context it applies in was never asked there. Here we see that the rejection is not universal: leaving a local variable unused is rejected, leaving a package-level constant unused is not. There is a reason for this — a package-level constant can be used somewhere entirely different, even in another file of the same package or one exported to a different package altogether; the compiler, reading a single file, cannot know whether that use will happen somewhere else later. A local variable, on the other hand, is bound to its own function body, and the compiler can see in the same glance whether it is used or not.

iota also has a way of being ignored: if the blank identifier _ is written instead of a name on a line, iota still increases, but no name is produced for that position. This is another use of the blank identifier measured in the first lesson — there it was a way to pass over an unused value without naming it; here it is a way to skip a number in the sequence. In both cases the same word carries the same meaning: “I know this, but I will not bind it to a name.”

Limiting Measurement: Short Declaration Does Not Work Everywhere

Short declaration is not as universal as it appears. To measure this, we return to the first lesson’s compiler call: we will try the same line in two different contexts and classify the result.

// compiler.go — helper that calls the compiler from inside the program
package main

import (
	"os"
	"os/exec"
	"path/filepath"
)

func compiles(source string) bool {
	dir, err := os.MkdirTemp("", "trial")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
	cmd := exec.Command("go", "build", "./...")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	return cmd.Run() == nil
}

func classify(ok bool) string {
	if ok {
		return "compiled"
	}
	return "not compiled"
}

Try the same x := 5 line in two different contexts: once outside a function, at package level; once inside a function.

// limiter.go — is short declaration rejected at package level
package main

import "fmt"

func main() {
	packageLevel := `package trial
x := 5
func F() { _ = x }`
	functionLevel := `package trial
func F() { x := 5; _ = x }`

	fmt.Printf("%-24s %s\n", "at package level", classify(compiles(packageLevel)))
	fmt.Printf("%-24s %s\n", "inside a function", classify(compiles(functionLevel)))
}
at package level         not compiled
inside a function        compiled

LB9. Both sources carry the same x := 5 line; the only difference is whether that line sits inside or outside a function body. The compiler only recognizes short declaration as a valid statement inside a function body; at package level it expects a declaration header like var or const, not :=.

This boundary shows why short declaration is not a “shortcut” but a context-specific piece of syntax. Short declaration saves you from writing the word var and a type name over and over while defining many variables one after another inside a function; at package level there is no need for this shortcut, because package-level declarations are already mostly written one at a time, with var or const, and their initialization order is resolved not by file order but by dependency order — that resolution will come up again in the next lesson, over basic types.

This rejection belongs to a different class than the four rejections in the first lesson. There, the four rejected sources were syntactically valid; the compiler merely did not want a particular pattern. Here x := 5 is syntactically invalid at package level — the language’s grammar only recognizes the := sign in a statement context, and package level is not a statement context, it is a declaration list. This shows that the “rejected” class itself has two subtypes: a pattern can be grammatically valid but the compiler does not want it (the first lesson’s four rejections), or the pattern itself is never defined in that context (this lesson’s rejection). Both produce the same not compiled answer, but one is a rule violation, the other a grammar mismatch.

The practical consequence of this distinction is that the writing form has to change when a value is meant to be defined at package level — moving from short declaration to var is not a preference, it is a requirement. A line moved from inside a function to package level does not just change its location; its writing form has to change too.

This lesson’s three measurements — short declaration’s type inference, iota‘s filling of constants, short declaration’s rejection at package level — are three faces of the same question. In all three, something is unwritten: a type name, four constant values, the validity of a declaration form. In all three, the language does not leave the gap random: in one it fills it by looking at a value, in one it fills it with a counter and a repetition rule, in one it finds nothing to fill it with and rejects. The course’s first two lessons measured “what gets rejected” and “what is mandatory”; this lesson measures an in-between region that is neither rejected nor mandatory — inference.

Summary

  • Short declaration (:=) infers a variable’s type from the assigned value’s own type, without ever writing it in the source.
  • The var name type = value form lets the writer set the result of inference by hand; this is not a conversion, it is an initial value assignment.
  • In a six-constant iota sequence, only two have a value written in the source; the other four are produced from the line’s own iota value combined with a repeated previous expression.
  • iota is a single counter across an entire const block and does not reset when a second expression appears.
  • The same short-declaration line compiles inside a function body but is rejected at package level — short declaration is a valid statement only inside a function.

Next Step

In this lesson a variable’s type was always inferred from a value — int from 42, float64 from 3.14. But those types themselves were never questioned: what happens when an integer overflows, what should be expected when two floating-point values are compared, and what is the rune type actually a stand-in for? The next lesson takes up basic types by measuring what each of them does in an unwritten case.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close