Table of Contents
- Numeric Conversions
- String Conversions
- Slice and Array Conversions
- Pointer Conversions
- Interface Conversions
- Struct Conversions
- Channel Conversions
- Map Conversions
- Function Conversions
- Unsafe Conversions
Numeric Conversions
Integer to Integer Conversions
Basic Concept
When you convert an integer from one size to another, you're essentially telling Go: "Take this number and fit it into a different-sized box." Sometimes the new box is bigger (widening), sometimes it's smaller (narrowing).
Code Example
govar i8 int8 = 127 // 8-bit signed integer (-128 to 127) var i16 int16 = int16(i8) // Convert to 16-bit var i32 int32 = int32(i16) // Convert to 32-bit var i64 int64 = int64(i32) // Convert to 64-bit var back8 int8 = int8(i64) // Convert back to 8-bit (may lose data!)
Memory Layout
Let's see how the number is stored in different integer sizes:
code
-1 codeint8(-1): [11111111] (1 byte) int16(-1): [11111111][11111111] (2 bytes) int32(-1): [11111111][11111111][11111111][11111111] (4 bytes)
Each box represents one byte (8 bits). Notice how the pattern of 1s extends as the size increases.
code
[]What Happens During Widening (Small → Large)
When you convert a smaller integer to a larger one, Go needs to fill the extra space. For signed integers, it performs sign extension:
- Look at the leftmost bit (the sign bit)
- If it's 1 (negative number), fill all new bits with 1s
- If it's 0 (positive number), fill all new bits with 0s
Example: int8(127) to int16
codeint8: [01111111] (127 in binary) ↓ sign bit is 0, so extend with 0s int16: [00000000][01111111] (still 127)
Example: int8(-1) to int16
codeint8: [11111111] (-1 in binary, all 1s in two's complement) ↓ sign bit is 1, so extend with 1s int16: [11111111][11111111] (still -1)
For unsigned integers, it always fills with zeros:
codeuint8: [11111111] (255) ↓ extend with 0s uint16: [00000000][11111111] (still 255)
What Happens During Narrowing (Large → Small)
When converting to a smaller size, Go simply chops off the high-order (leftmost) bits and keeps only the rightmost bits that fit.
Example: int16(300) to int8
codeint16(300): [00000001][00101100] (300 in binary) ^^^^^^^^ ^^^^^^^^ throw keep only away these bits ↓ int8: [00101100] (44 in decimal!)
This is why converting (which needs 9 bits) to (only 8 bits) gives you - you lose the leftmost bit!
code
300code
int8code
44Compiler Behavior
The Go compiler handles these conversions differently:
- Compile-time constants: If both values are known at compile time, the compiler does the conversion and may warn about overflow
- Runtime values: The conversion happens using CPU instructions (like MOVSX for sign-extension)
Runtime Behavior
At runtime, these conversions are extremely fast - usually just one or two CPU instructions:
- Widening: Uses instructions like
(move with sign extension) orcodeMOVSX (move with zero extension)codeMOVZX - Narrowing: Simply copies the lower bytes, ignoring the rest
Summary
- Widening: Safe, no data loss, sign-extends for signed types
- Narrowing: Dangerous, may lose data, truncates high bits
- Cost: O(1) - single CPU instruction
- Safety: No automatic conversion - must be explicit
Integer to Float Conversions
Basic Concept
Converting an integer to a floating-point number means representing a whole number as a fraction with a decimal point. Think of it like writing as - same value, different representation.
code
42code
42.0Code Example
govar i int = 42 var f32 float32 = float32(i) // 42 becomes 42.0 var f64 float64 = float64(i) // More precise version var largeInt int = 16777217 var f32Large float32 = float32(largeInt) // May lose precision! fmt.Println(f32Large) // Might print 16777216.0
Memory Layout
Integers and floats are stored completely differently:
Integer Storage (int32):
code[00000000][00000000][00000000][00101010] = 42 ↑ ↑ high bits low bits
Float Storage (float32) using IEEE 754:
code[S][EEEEEEEE][MMMMMMMMMMMMMMMMMMMMMMM] ↑ ↑ ↑ sign exponent mantissa (fraction part) For 42.0: [0][10000100][01010000000000000000000]
How the Conversion Works
Let me explain the IEEE 754 floating-point format step by step:
Step 1: Understand Float Components
A float has three parts:
- Sign bit: 0 for positive, 1 for negative
- Exponent: Tells you where the decimal point is
- Mantissa: The actual digits of the number
Step 2: Converting 42 to Float32
- Start with integer:
in binary iscode42code101010 - Normalize it:
(move decimal point)code1.01010 × 2^5 - Store:
- Sign:
(positive)code0 - Exponent:
=code5 + 127 = 132 (biased by 127)code10000100 - Mantissa:
(drop the leading 1)code01010000000000000000000
- Sign:
Step 3: Precision Limits
Float32 has only 23 bits for the mantissa (plus 1 implied bit = 24 bits total). This means:
- Can represent about 7 decimal digits precisely
- Large integers may round to nearest representable value
Example of Precision Loss:
codeInteger: 16777217 (needs 25 bits) [1][0000000][0000000000000001] ↑ implied ↑ 23-bit mantissa ↑ extra bit! Float32 can't store that extra bit, so it rounds: Result: 16777216 (the nearest representable float32)
Visual Example of Rounding
codeInteger range: ... 16777215, 16777216, 16777217, 16777218 ... ↑ ↑ Float32 can represent these: 16777216 16777218 ↑ 16777217 rounds here
Compiler and Runtime Behavior
Compile-time:
- If value is a constant, compiler does the conversion
- May warn if precision loss is detected
Runtime:
- Uses CPU's floating-point conversion instructions (like
on x86)codeCVTSI2SS - Hardware handles the IEEE 754 conversion
- Very fast (1-2 cycles on modern CPUs)
Summary
- Process: Integer → binary → normalized form → IEEE 754 format
- Precision: float32 ≈ 7 digits, float64 ≈ 15 digits
- Data Loss: Possible for large integers in float32
- Cost: O(1) - single CPU instruction
- Safety: Always succeeds, but may round
Float to Integer Conversions
Basic Concept
Converting a float to an integer means throwing away everything after the decimal point. It's like rounding down to the nearest whole number, but not quite - it actually truncates toward zero.
Code Example
govar f float64 = 42.7 var i int = int(f) // i = 42 (decimal part discarded) var f2 float64 = 42.9 var i2 int = int(f2) // i2 = 42 (still truncates, doesn't round!) var f3 float64 = -42.7 var i3 int = int(f3) // i3 = -42 (truncates toward zero) // For actual rounding, use math functions: import "math" rounded := int(math.Round(42.7)) // 43 floored := int(math.Floor(42.7)) // 42 ceiled := int(math.Ceil(42.7)) // 43
Memory Layout Transformation
Float64 (42.7):
code[0][10000000100][0101010110011001100110011001100110011001100110011010] ↑ ↑ ↑ sign exponent mantissa (represents .7 part)
After conversion to int32:
code[00000000][00000000][00000000][00101010] = 42 ↑ Everything after decimal point is GONE
How Truncation Works
Let's visualize the number line:
code-43 -42 -41 0 41 42 43 |-----|-----|-----|-----|-----|-----| ↑ ↑ ↑ -42.7 → -42 0.7 → 0 42.7 → 42 (toward zero) (toward zero)
Compare this to floor (always toward negative infinity):
code-43 -42 -41 0 41 42 43 |-----|-----|-----|-----|-----|-----| ↑ ↑ ↑ -42.7 → -43 0.7 → 0 42.7 → 42 (floor) (floor) (floor)
Step-by-Step Conversion Process
Converting 42.7 to int:
-
Decode the float (IEEE 754):
- Extract mantissa and exponent
- Calculate actual value: 42.7
-
Isolate integer part:code
42.7 ↓ truncate 42 -
Store as integer:
- Convert 42 to binary: code
101010 - Pad with zeros: code
00000000000000000000000000101010
- Convert 42 to binary:
Overflow Behavior
What happens if the float is too large for the integer type?
govar huge float64 = 1e20 // 100,000,000,000,000,000,000 var i int32 = int32(huge) // UNDEFINED BEHAVIOR!
The result is implementation-dependent. Different architectures may:
- Wrap around (modulo arithmetic)
- Give maximum/minimum value
- Give garbage
Safe conversion:
goif huge <= math.MaxInt32 && huge >= math.MinInt32 { i := int32(huge) // Safe to use } else { // Handle overflow }
Compiler and Runtime Behavior
Compile-time:
- If converting a constant, compiler does it at compile time
- May warn about obvious overflow
Runtime:
- Uses CPU instructions like
(convert with truncation)codeCVTTSS2SI - The "TT" in the instruction means "truncate toward zero"
- Very fast operation
Visual Guide: Different Rounding Methods
govalue := 42.7 // int() - Truncate toward zero int(42.7) = 42 [42.0 42.7 43.0] ↓ 42 // math.Floor() - Always toward negative infinity math.Floor(42.7) = 42 [42.0 42.7 43.0] ↓ 42 // math.Ceil() - Always toward positive infinity math.Ceil(42.7) = 43 [42.0 42.7 43.0] ↓ 43 // math.Round() - Nearest integer (0.5 rounds to even) math.Round(42.7) = 43 [42.0 42.7 43.0] ↓ 43 math.Round(42.5) = 42 [42.0 42.5 43.0] ↓ 42 (even number)
Summary
- Method: Truncates toward zero (not rounding!)
- Decimal Lost: Everything after decimal point discarded
- Overflow: Undefined behavior if too large
- Cost: O(1) - single CPU instruction
- Safety: No bounds checking - check manually if needed
Float to Float Conversions
Basic Concept
Converting between float32 and float64 is like moving a number between two different-sized containers. Float64 is a bigger container with more precision, while float32 is smaller but uses less memory.
Code Example
govar f64 float64 = 3.14159265358979323846 // Pi with 20 digits var f32 float32 = float32(f64) // Loses precision fmt.Println(f64) // 3.14159265358979323846 fmt.Println(f32) // 3.1415927 (only ~7 digits preserved) var back64 float64 = float64(f32) // Exact conversion fmt.Println(back64) // 3.1415927410125732... (not original!)
Memory Layout Comparison
Float32 (32 bits total):
code[S][EEEEEEEE][MMMMMMMMMMMMMMMMMMMMMMM] 1 8 bits 23 bits ↑ ↑ ↑ sign exponent mantissa
Float64 (64 bits total):
code[S][EEEEEEEEEEE][MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM] 1 11 bits 52 bits ↑ ↑ ↑ sign exponent mantissa
Precision Comparison
Let's visualize what each type can accurately represent:
Float32 precision (~7 decimal digits):
code3.1415927... ↑↑↑↑↑↑↑ reliable digits Rest is either: - Rounded - Zero-padded - Garbage
Float64 precision (~15 decimal digits):
code3.141592653589793... ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ reliable digits
How Float64 → Float32 Conversion Works
Step 1: Extract components from float64
codeOriginal: 3.14159265358979323846 Float64 format: [0][10000000000][1001001000011111101101010100010001000010110100011000] exponent mantissa (52 bits)
Step 2: Round mantissa to 23 bits
codeOriginal 52-bit mantissa: [10010010000111111011010101000100 01000010110100011000...] ↑ Keep only first 23 bits ↑ ↑ These bits are lost ↑ After rounding (23 bits): [10010010000111111011011] ↑ rounded up
Step 3: Store in float32 format
code[0][10000000][10010010000111111011011] 8-bit 23-bit mantissa exponent
Rounding Rules (IEEE 754)
The standard uses "round to nearest, ties to even" rule:
Example 1: Clear rounding
codeOriginal bits: ...0101001[1]1000... ↑ next bit = 1, more bits follow Round UP Result: ...0101010
Example 2: Exact tie (0.5)
codeOriginal bits: ...0101001[1]0000... ↑ next bit = 1, but followed by zeros This is exactly 0.5 Round to EVEN (look at last kept bit) If last kept bit is 0: keep it (already even) If last kept bit is 1: round up (make it even)
Visual Example of Precision Loss
gooriginal := 3.14159265358979323846 // In memory (simplified): Float64: 3.14159265358979323846 ↑ ↑ | extra precision | Float32: 3.1415927 ↑ ↑ | approximation starts here exact
When you convert back to float64:
gof32 := float32(3.14159265358979323846) // 3.1415927 f64 := float64(f32) // 3.1415927410125732... // ↑ garbage digits!
Those extra digits in the float64 are NOT meaningful - they're just what happens when the less-precise float32 value gets expanded to 64 bits.
How Float32 → Float64 Conversion Works
This is simpler because we're going to a larger container:
Step 1: Extract from float32
code[0][10000000][10010010000111111011011] 8-bit 23-bit mantissa
Step 2: Extend to float64 format
codeExponent: Convert 8-bit to 11-bit (adjust bias: 127 → 1023) Mantissa: Pad 23 bits to 52 bits with zeros [0][10000000000][1001001000011111101101100000000000000000000000000000000] 11-bit 52-bit mantissa (padded with zeros)
Step 3: Result
The value is exactly represented, but those zero-padded bits don't add precision - they just fill space.
Compiler and Runtime Behavior
Compile-time:
- If value is constant, compiler pre-converts
- May warn about precision loss
Runtime:
- float64 → float32: Uses
(convert scalar double to single)codeCVTSD2SS - float32 → float64: Uses
(convert scalar single to double)codeCVTSS2SD - Hardware handles IEEE 754 rounding automatically
Real-World Example
go// Scientific calculation needing precision var piDouble float64 = 3.14159265358979323846 // Graphics rendering (less precision needed, saves memory) var piFloat float32 = float32(piDouble) // 3.1415927 // Memory usage: // float64: 8 bytes per number // float32: 4 bytes per number // In a 3D vertex array with 1,000,000 vertices: saves 4MB!
Precision Loss Table
| Operation | Precision | Notes |
|---|---|---|
| float64 → float64 | 15-17 digits | No loss |
| float32 → float64 | 6-9 digits | Exact value, but no added precision |
| float64 → float32 | 6-9 digits | Loses precision, rounds |
| float32 → float64 → float32 | 6-9 digits | Loses precision permanently |
Summary
- float64 → float32: Loses precision, rounds using IEEE 754 rules
- float32 → float64: Exact conversion, but doesn't add real precision
- Rounding: "Round to nearest, ties to even"
- Cost: O(1) - single CPU instruction
- Use float32: When memory matters more than precision (graphics, ML)
- Use float64: When precision matters (science, finance)
Complex Number Conversions
Basic Concept
A complex number has two parts: real and imaginary (like ). In Go, uses two values, and uses two values. Converting between them is like converting two separate floats at once.
code
3 + 4icode
complex64code
float32code
complex128code
float64Code Example
go// Creating complex numbers var c64 complex64 = 3 + 4i var c128 complex128 = complex128(c64) // Widen to higher precision // Extracting parts var realPart float64 = real(c128) // Get real part: 3.0 var imagPart float64 = imag(c128) // Get imaginary part: 4.0 // Constructing from parts var newComplex complex128 = complex(realPart, imagPart) // 3 + 4i // Converting with floats var r float64 = 3.14159265358979 var i float64 = 2.71828182845904 var c complex128 = complex(r, i) // 3.14159... + 2.71828...i
Memory Layout
complex64 (8 bytes total):
code[RRRRRRRR][IIIIIIII] 4 bytes 4 bytes ↑ ↑ float32 float32 (real) (imaginary) Example: 3 + 4i Memory: [3.0 as float32][4.0 as float32]
complex128 (16 bytes total):
code[RRRRRRRRRRRRRRRR][IIIIIIIIIIIIIIII] 8 bytes 8 bytes ↑ ↑ float64 float64 (real) (imaginary) Example: 3 + 4i Memory: [3.0 as float64][4.0 as float64]
Visual Representation on Complex Plane
codeImaginary Axis (i) ↑ 4i | • (3 + 4i) | / 3i | / |/ -----+----→ Real Axis |3 |
Each complex number is a point in 2D space:
- Horizontal position = real part
- Vertical position = imaginary part
How Complex Conversions Work
Converting complex64 → complex128:
codeOriginal complex64: (3.0 + 4.0i) ↓ ↓ float32 float32 Step 1: Extract both parts real_part = 3.0 (as float32) imag_part = 4.0 (as float32) Step 2: Convert each part float32 → float64 real_part_64 = float64(3.0) ← exact conversion imag_part_64 = float64(4.0) ← exact conversion Step 3: Combine into complex128 result = complex(real_part_64, imag_part_64) ↓ ↓ float64 float64 Result: (3.0 + 4.0i) as complex128
Converting complex128 → complex64:
codeOriginal complex128: (3.14159265358979 + 2.71828182845904i) ↓ ↓ float64 float64 Step 1: Extract both parts real_part = 3.14159265358979 imag_part = 2.71828182845904 Step 2: Convert each part float64 → float32 (LOSES PRECISION!) real_part_32 = float32(3.14159265358979) → 3.1415927 imag_part_32 = float32(2.71828182845904) → 2.7182817 Step 3: Combine into complex64 result = complex(real_part_32, imag_part_32) Result: (3.1415927 + 2.7182817i) as complex64 ↑ precision lost here
Extracting and Constructing
Using real() and imag():
These are built-in functions that reach into the memory and extract each part:
codeComplex number in memory: [RRRRR...][IIIII...] ↑ ↑ | imag() reads this real() reads this Example with complex128(3 + 4i): Memory layout: [0x4008000000000000][0x4010000000000000] ↑ real: 3.0 ↑ imag: 4.0 real(c) → returns float64(3.0) imag(c) → returns float64(4.0)
Using complex():
This function takes two floats and packs them together:
codeInput: real_val = 3.0 imag_val = 4.0 float64 float64 Pack them side by side in memory: [3.0 as float64][4.0 as float64] = complex128(3 + 4i)
Mathematical Operations Visualization
goc1 := complex(3, 4) // 3 + 4i c2 := complex(1, 2) // 1 + 2i sum := c1 + c2 // (3+1) + (4+2)i = 4 + 6i
Visual addition on complex plane:
code6i | | • result (4 + 6i) 4i | • c1 | ↗ 2i |• c2 | ---+---→ 1 3 4
Compiler and Runtime Behavior
Compile-time:
- Complex literals are computed at compile time
- Operations on constant complex numbers are pre-calculated
Runtime:
-
Complex numbers are stored as two adjacent floats
-
Operations are split into real and imaginary parts:code
(a + bi) + (c + di) = (a+c) + (b+d)i CPU does two float additions: 1. real_result = a + c 2. imag_result = b + d -
No special CPU instructions for complex math
-
Each operation is decomposed into multiple float operations
Precision Table
| Type | Real Precision | Imaginary Precision | Total Size |
|---|---|---|---|
| complex64 | ~7 digits (float32) | ~7 digits (float32) | 8 bytes |
| complex128 | ~15 digits (float64) | ~15 digits (float64) | 16 bytes |
Real-World Example
go// Electrical engineering: impedance calculation // Z = R + jX (resistance + reactance) resistance := 100.0 // Ohms reactance := 50.0 // Ohms impedance := complex(resistance, reactance) // 100 + 50i // Extract magnitude (using Pythagorean theorem) magnitude := math.Sqrt(real(impedance)*real(impedance) + imag(impedance)*imag(impedance)) // magnitude = √(100² + 50²) = 111.8 Ohms // Extract phase angle phase := math.Atan2(imag(impedance), real(impedance)) // phase = arctan(50/100) = 26.57 degrees
Summary
- Structure: Two floats stored side-by-side (real + imaginary)
- complex64: 8 bytes (two float32s), ~7 digit precision each
- complex128: 16 bytes (two float64s), ~15 digit precision each
- Widening: Exact (complex64 → complex128)
- Narrowing: Loses precision (complex128 → complex64)
- Extraction:
andcodereal() built-in functionscodeimag() - Construction:
built-in functioncodecomplex(r, i) - Cost: O(1) for conversions, but operations cost 2× float operations
Boolean Conversions
Basic Concept
In many languages, you can treat numbers as booleans (0 = false, anything else = true). Go explicitly forbids this! You must always be explicit about what you mean by "true" or "false."
Code Example
go// These DON'T work - Go won't compile them: // var i int = 1 // var b bool = bool(i) // ERROR: cannot convert // var b2 bool = i // ERROR: cannot use int as bool // if i { } // ERROR: non-bool used as condition // The correct way - be explicit: var i int = 1 var b bool = (i != 0) // Explicitly check "is it not zero?" var b2 bool = (i == 1) // Explicitly check "is it equal to 1?" if i != 0 { // Explicit comparison fmt.Println("i is not zero") } // Converting bool to int (also no direct way): var flag bool = true // var num int = int(flag) // ERROR: cannot convert // Correct approach: var num int if flag { num = 1 } else { num = 0 } // Or using a helper function: func boolToInt(b bool) int { if b { return 1 } return 0 }
Memory Layout
Boolean storage:
codebool value in memory: [00000000] or [00000001] 1 byte ↓ false=0, true=1 Actually uses a full byte even though only 1 bit is needed!
Why a full byte?
- CPUs work with bytes, not bits
- Accessing individual bits requires extra operations (masking, shifting)
- Using a byte makes boolean operations fast
- Memory is cheap
Why Go Forbids Implicit Boolean Conversion
Problem in C (what Go avoids):
c// C code - legal but dangerous int bytes_read = read(file, buffer, size); if (bytes_read) { // Oops! -1 (error) also counts as "true" process_data(buffer); } // Should be: if (bytes_read > 0) { // Explicitly check for success process_data(buffer); }
Go forces you to be clear:
gobytesRead, err := file.Read(buffer) if err != nil { // Explicit error check return err } if bytesRead > 0 { // Explicit success check processData(buffer) }
Common Patterns and Idioms
Pattern 1: Checking if value exists/is valid
go// Integer check var count int = getUserCount() hasUsers := (count > 0) // Pointer check var ptr *int = getPointer() isValid := (ptr != nil) // String check var name string = getName() hasName := (name != "")
Pattern 2: Combining conditions
goage := 25 hasLicense := true // Multiple conditions canDrive := (age >= 18) && hasLicense needsSupervision := (age < 18) || !hasLicense
Pattern 3: Boolean algebra
goa := true b := false // AND: both must be true result1 := a && b // false // OR: at least one must be true result2 := a || b // true // NOT: flip the value result3 := !a // false // XOR: exactly one must be true (not built-in, but can construct) result4 := (a || b) && !(a && b) // true
Compiler Behavior
The compiler enforces type safety at compile time:
go// Compilation fails immediately: var x int = 5 if x { // Compile error: "non-bool x used as if condition" // ... } // Compiler error message is clear: // "cannot use x (type int) as type bool in if statement"
Runtime Representation
At runtime, booleans are simple:
codeMemory representation: false: [0x00] (byte with value 0) true: [0x01] (byte with value 1) CPU operations: - Comparison result directly produces bool - Branch instructions use bool values directly - Very fast: single instruction
Comparison with Other Languages
C/C++:
cint x = 0; if (x) { } // Legal: 0 is false, anything else is true
Python:
pythonx = 0 if x: # Legal: 0, None, "", [] are all false pass
Go:
govar x int = 0 if x != 0 { // Must be explicit! // ... }
Why This Design Choice?
Go's philosophy: Explicit is better than implicit
-
Prevents bugs:go
// Prevents writing: if err { // ERROR: err is an error, not a bool // ... } // Forces correct: if err != nil { // ... } -
Improves readability:go
// Clear intention if count > 0 { // "there are items" } // vs unclear (if it were allowed) if count { // "count is... truthy? non-zero? what?" } -
Catches typos:go
// Typo caught at compile time: if x = 5 { // ERROR: assignment, not comparison // ... } // Correct: if x == 5 { // ... }
Summary
- No implicit conversion: Cannot convert between bool and numeric types
- Must be explicit: Use comparisons like
,code!= 0 ,code> 0code== nil - Size: 1 byte in memory (even though only needs 1 bit)
- Philosophy: Prevents bugs, improves clarity
- Cost: Zero - comparisons are free (compiler optimizes)
- Type safety: Enforced at compile time
String Conversions
String to []byte Conversion
Basic Concept
Strings in Go are immutable (you can't change them after creation), while byte slices ( ) are mutable (you can change any byte). Converting a string to creates a new, separate copy that you can modify without affecting the original string.
code
[]bytecode
[]byteThink of it like photocopying a book: the original stays the same, and you can write notes on your copy.
Code Example
gos := "Hello, 世界" b := []byte(s) // Create a mutable copy // Now you can modify b b[0] = 'h' // Change 'H' to 'h' fmt.Println(string(b)) // "hello, 世界" fmt.Println(s) // "Hello, 世界" (original unchanged) // The slice owns its own memory b[1] = 'a' b[2] = 'l' fmt.Println(string(b)) // "hallo, 世界"
Memory Layout
Let's see how a string and byte slice are stored:
String structure:
codeString "Hello" in memory: String header (16 bytes on 64-bit): ┌──────────────┬─────────┐ │ pointer │ length │ │ (8 bytes) │(8 bytes)│ └──────┬───────┴─────────┘ │ ↓ Actual string data (read-only): ['H']['e']['l']['l']['o'] 72 101 108 108 111 (ASCII codes)
After converting to []byte:
codeOriginal string (unchanged): String header → ['H']['e']['l']['l']['o'] New []byte slice: Slice header (24 bytes): ┌──────────────┬─────────┬──────────┐ │ pointer │ length │ capacity │ │ (8 bytes) │(8 bytes)│(8 bytes) │ └──────┬───────┴─────────┴──────────┘ │ ↓ New copy of data (writable): ['H']['e']['l']['l']['o'] 72 101 108 108 111
Step-by-Step Conversion Process
Let's trace what happens with "Hello, 世界":
Step 1: Measure the string
codeString: "Hello, 世界" Byte representation in UTF-8: 'H' = [72] 'e' = [101] 'l' = [108] 'l' = [108] 'o' = [111] ',' = [44] ' ' = [32] '世' = [228, 184, 150] ← 3 bytes in UTF-8! '界' = [231, 149, 140] ← 3 bytes in UTF-8! Total: 13 bytes
Step 2: Allocate new memory
codeAllocate 13 bytes on the heap: [??][??][??][??][??][??][??][??][??][??][??][??][??] ↑ uninitialized memory
Step 3: Copy each byte
codeSource (string): [72][101][108][108][111][44][32][228][184][150][231][149][140] ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ Destination: [72][101][108][108][111][44][32][228][184][150][231][149][140] ([]byte slice) H e l l o , ' ' 世(byte1,2,3) 界(byte1,2,3)
Step 4: Create slice header
codeSlice header: ┌─────────────────┬────────┬──────────┐ │ pointer to data │ len=13 │ cap=13 │ └─────────────────┴────────┴──────────┘
Why This Takes Time and Space
Time Complexity: O(n) where n = number of bytes
codeFor a 1,000,000 byte string: - Must copy all 1,000,000 bytes - Each byte copy is one memory operation - Cannot be done in O(1)
Space Complexity: O(n)
codeOriginal string: 1,000,000 bytes New []byte: 1,000,000 bytes Total: 2,000,000 bytes (double the memory!)
Compiler and Runtime Behavior
What the compiler generates:
gos := "Hello" b := []byte(s) // Roughly translates to: // 1. newLen := len(s) // 2. newBytes := make([]byte, newLen) // 3. copy(newBytes, s) // 4. b = newBytes
Runtime allocations:
code1. Check string length: O(1) - it's in the string header 2. Call runtime.makeslice(typ, len, cap): - Calculates needed memory: 13 bytes - Asks allocator for memory (heap or stack) - If small enough (< 32KB), may use stack - If large, allocates on heap 3. Call runtime.stringtoslicebyte(s): - Uses optimized copy routine (may use SIMD) - Copies 8-16 bytes at a time on modern CPUs 4. Returns slice header pointing to new memory
Real-World Performance Example
go// BAD: Converting in a tight loop for i := 0; i < 1000000; i++ { b := []byte("some data") // Allocates 9 bytes 1 million times! process(b) // 9 MB total allocation } // GOOD: Convert once, reuse data := []byte("some data") // Allocate once for i := 0; i < 1000000; i++ { process(data) // Reuse same slice } // BETTER: Pre-allocate if modifying buffer := make([]byte, 100) // Pre-allocate buffer for i := 0; i < 1000000; i++ { n := copy(buffer, "some data") // Copy into existing buffer process(buffer[:n]) // Use only filled portion }
When UTF-8 Matters
go// ASCII string (1 byte per character) ascii := "Hello" bytesAscii := []byte(ascii) fmt.Println(len(ascii)) // 5 bytes fmt.Println(len(bytesAscii)) // 5 bytes // Multi-byte UTF-8 string chinese := "世界" bytesChinese := []byte(chinese) fmt.Println(len(chinese)) // 6 bytes fmt.Println(len(bytesChinese)) // 6 bytes (each character = 3 bytes) // Rune count vs byte count fmt.Println(len([]rune(chinese))) // 2 runes (2 characters)
Visual Memory Comparison
codeOriginal string "世": String object (16 bytes overhead): ┌────────────┐ │ header │ ← 16 bytes on 64-bit └──────┬─────┘ │ ↓ [0xE4][0xB8][0x96] ← 3 bytes of actual data 228 184 150 After []byte conversion: Slice object (24 bytes overhead): ┌────────────┐ │ header │ ← 24 bytes on 64-bit └──────┬─────┘ │ ↓ [0xE4][0xB8][0x96] ← 3 bytes (NEW copy) 228 184 150 Total overhead: 24 bytes + 3 bytes = 27 bytes for 1 character!
Summary
- Creates a copy: New memory allocated, all bytes copied
- Mutability: Original string unchanged, slice can be modified
- Time: O(n) where n = byte length
- Space: O(n) additional memory
- UTF-8: Copies exact byte representation
- Overhead: 24 bytes for slice header + data size
- Use when: Need to modify string data or build strings dynamically
[]byte to String Conversion
Basic Concept
Converting a byte slice to a string creates an immutable string from your mutable byte data. Like making a final, unchangeable document from a draft you've been editing.
Important: Go copies the bytes by default, so the string and slice have independent memory. This protects the string's immutability.
Code Example
gob := []byte{72, 101, 108, 108, 111} // ASCII for "Hello" s := string(b) fmt.Println(s) // "Hello" // Original slice can be modified b[0] = 104 // Change to 'h' fmt.Println(s) // Still "Hello" (immutable!) fmt.Println(string(b)) // "hello" (new conversion shows change) // Multi-byte characters work too utf8Bytes := []byte{0xE4, 0xB8, 0x96} // UTF-8 for '世' chineseStr := string(utf8Bytes) fmt.Println(chineseStr) // "世"
Memory Layout
Starting with []byte:
codeSlice header (24 bytes): ┌──────────────┬─────────┬──────────┐ │ pointer │ len=5 │ cap=5 │ └──────┬───────┴─────────┴──────────┘ │ ↓ Mutable byte array: [72][101][108][108][111] 'H' 'e' 'l' 'l' 'o'
After string(b):
codeOriginal []byte (unchanged): Slice header → [72][101][108][108][111] (can still be modified) New string: String header (16 bytes): ┌──────────────┬─────────┐ │ pointer │ len=5 │ └──────┬───────┴─────────┘ │ ↓ New immutable data: [72][101][108][108][111] 'H' 'e' 'l' 'l' 'o' (separate copy, read-only)
Step-by-Step Conversion
Let's trace (the UTF-8 bytes for '世'):
code
string([]byte{228, 184, 150})Step 1: Examine input
codeInput slice: [228][184][150] ↓ ↓ ↓ 0xE4 0xB8 0x96 (in hexadecimal) These bytes form the UTF-8 encoding of '世'
Step 2: Allocate string memory
codeAllocate 3 bytes for string data: [??][??][??] ↑ uninitialized
Step 3: Copy bytes
codeFrom slice: [228][184][150] ↓ ↓ ↓ To string: [228][184][150]
Step 4: Create string header
codeString header: ┌──────────────────────┬────────┐ │ pointer to new data │ len=3 │ └──────────────────────┴────────┘
Step 5: NO UTF-8 validation
codeImportant: Go does NOT validate the bytes! Valid UTF-8: [228][184][150] → string "世" ✓ Invalid UTF-8: [255][254][253] → string with invalid UTF-8 ✗ (no error, just invalid string)
UTF-8 Validation (or Lack Thereof)
Go will happily create strings with invalid UTF-8:
go// Invalid UTF-8 bytes invalidBytes := []byte{0xFF, 0xFE, 0xFD} invalidStr := string(invalidBytes) // No error! fmt.Println(invalidStr) // Prints garbage or replacement characters // Check if string is valid UTF-8 import "unicode/utf8" isValid := utf8.ValidString(invalidStr) fmt.Println(isValid) // false // Valid UTF-8 validBytes := []byte{0xE4, 0xB8, 0x96} // '世' validStr := string(validBytes) fmt.Println(utf8.ValidString(validStr)) // true
Why No Validation?
Performance:
codeWith validation: Must check every byte sequence - Time: O(n) with extra overhead - Cannot optimize away Without validation: Just copy bytes - Time: O(n) pure memory copy - Much faster
Flexibility:
codeSometimes you want invalid strings: - Reading binary data - Partial UTF-8 sequences during streaming - Custom encodings
Compiler and Runtime Behavior
What happens at runtime:
gob := []byte{72, 101, 108, 108, 111} s := string(b) // Runtime roughly does: // 1. newLen := len(b) // 2. newStr := runtime.stringFromBytes(b) // └─ Allocates newLen bytes // └─ Copies bytes: memmove(newStr, b, newLen) // └─ Returns string header
Optimization for constants:
go// Compile-time known s1 := string([]byte{72, 101, 108, 108, 111}) // Compiler: "This is always 'Hello', skip conversion" // Optimizes to: s1 := "Hello" // Runtime value b := getUserInput() s2 := string(b) // Compiler: "Must convert at runtime" // No optimization possible
Memory Allocation Behavior
Small strings (< 32 bytes typically):
codeMay allocate on stack: ┌─────────────┐ │ Stack │ │ [string data] ← fast allocation └─────────────┘
Large strings (> 32 bytes typically):
codeAllocates on heap: ┌─────────────┐ │ Heap │ │ [string data] ← slower, but can grow └─────────────┘ Stack just holds the header: ┌──────────┐ │ Stack │ │ header ─┼─→ heap data └──────────┘
Performance Characteristics
Time Complexity: O(n)
codeFor n bytes: - Must copy all n bytes - Modern CPUs: Copy 8-16 bytes per instruction (SIMD) - Still fundamentally O(n) Example: 1 byte: ~1 nanosecond 1,000 bytes: ~100 nanoseconds 1,000,000: ~100 microseconds
Space Complexity: O(n)
codeInput: []byte with n bytes Output: string with n bytes Total: 2n bytes (doubles memory usage)
Common Patterns
Pattern 1: Building strings efficiently
go// BAD: Multiple allocations result := "" for _, b := range bytes { result += string([]byte{b}) // New string each iteration! } // GOOD: Single conversion result := string(bytes)
Pattern 2: Buffer to string
govar buffer bytes.Buffer buffer.WriteString("Hello, ") buffer.WriteString("World") // Convert buffer to string (makes a copy) result := buffer.String()
Pattern 3: Reading file data
godata, err := ioutil.ReadFile("file.txt") // data is []byte if err != nil { return err } content := string(data) // Convert to string for text processing
Comparison with Zero-Copy (Unsafe) Method
Standard (safe) conversion:
gob := []byte{72, 101, 108, 108, 111} s := string(b) // Copies data // Memory: // []byte: [72][101][108][108][111] // string: [72][101][108][108][111] ← separate copy
Unsafe (zero-copy) conversion:
goimport "unsafe" b := []byte{72, 101, 108, 108, 111} s := *(*string)(unsafe.Pointer(&b)) // NO copy! // Memory: // []byte: [72][101][108][108][111] // ↑ string points here too // string: (points to same memory) // DANGER: Modifying b corrupts s! b[0] = 104 // Changes s too! fmt.Println(s) // "hello" (violated immutability!)
When to Convert
Convert []byte → string when:
code✓ Need immutable text ✓ Storing in a map key (maps require immutable keys) ✓ Comparing strings (string comparison is optimized) ✓ Returning from API (strings are safer interface)
Keep as []byte when:
code✓ Building/modifying text ✓ Reading/writing files or networks ✓ Performance-critical paths (avoid copying) ✓ Working with binary data
Summary
- Creates a copy: Allocates new memory, copies all bytes
- No validation: Invalid UTF-8 bytes become invalid strings
- Time: O(n) where n = byte length
- Space: O(n) additional memory
- Immutability: Result cannot be changed
- Overhead: 16 bytes for string header + data size
- Use when: Need immutable text or string operations
String to []rune Conversion
Basic Concept
Converting a string to means decoding the UTF-8 bytes into Unicode code points. Each rune represents one Unicode character, regardless of how many bytes it takes in UTF-8.
code
[]runeThink of it like translating a compressed file into its full form: "世" takes 3 bytes in UTF-8, but becomes a single rune (one number: 19990).
Code Example
gos := "Hello, 世界!" // Convert to runes runes := []rune(s) fmt.Println("String:", s) fmt.Println("Byte length:", len(s)) // 14 bytes fmt.Println("Rune length:", len(runes)) // 9 runes (characters) // Access individual characters correctly fmt.Printf("First char: %c\n", runes[0]) // 'H' fmt.Printf("7th char: %c\n", runes[7]) // '世' fmt.Printf("8th char: %c\n", runes[8]) // '界' // Compare with string indexing (WRONG for multi-byte chars) fmt.Printf("s[7] = %d (byte, not character!)\n", s[7]) // 228 (first byte of '世')
Memory Layout Transformation
Original String:
codeString "Hello, 世界!" String header (16 bytes): ┌──────────────┬────────┐ │ pointer │ len=14 │ └──────┬───────┴────────┘ │ ↓ UTF-8 encoded bytes (14 bytes total): ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐ │ H │ e │ l │ l │ o │ , │ │ 世 (3 bytes) │ 界 (3 bytes) │ ! │ ├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤ │72 │101│108│108│111│44 │32 │228│184│150│231│149│140│33 │ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘ 1 1 1 1 1 1 1 <─3─> <─3─> 1 ← bytes per character
After []rune(s):
codeSlice header (24 bytes): ┌──────────────┬────────┬────────┐ │ pointer │ len=9 │ cap=9 │ └──────┬───────┴────────┴────────┘ │ ↓ Array of runes (36 bytes total: 9 runes × 4 bytes each): ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │ H │ e │ l │ l │ o │ , │space│ 世 │ 界 │ ! │ ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ │ 72 │ 101 │ 108 │ 108 │ 111 │ 44 │ 32 │19990│30028│ 33 │ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ 4 4 4 4 4 4 4 4 4 ← bytes per rune
Notice: 14 bytes → 36 bytes (2.5× larger!)
Step-by-Step UTF-8 Decoding
Let's decode "世界" in detail:
Step 1: Read the string bytes
codeBytes: [0xE4][0xB8][0x96][0xE7][0x95][0x8C] └──────世──────┘ └──────界──────┘
Step 2: Decode first character '世'
codeFirst byte: 0xE4 = 11100100 in binary UTF-8 encoding rules: - Starts with 1110xxxx: This is a 3-byte sequence - Next 2 bytes should start with 10xxxxxx Byte 1: 1110-0100 Byte 2: 1011-1000 (0xB8) Byte 3: 1001-0110 (0x96) Extract the x bits: 1110-[0100] 10-[111000] 10-[010110] ^^^^ ^^^^^^ ^^^^^^ ↓ ↓ ↓ Combine: 0100 111000 010110 = 01001110 00010110 (binary) = 0x4E16 (hex) = 19990 (decimal) Result: rune(19990) = '世'
Step 3: Decode second character '界'
codeBytes: [0xE7][0x95][0x8C] Byte 1: 1110-0111 Byte 2: 1001-0101 (0x95) Byte 3: 1000-1100 (0x8C) Extract bits: 1110-[0111] 10-[010101] 10-[001100] ^^^^ ^^^^^^ ^^^^^^ ↓ ↓ ↓ Combine: 0111 010101 001100 = 01110101 01001100 (binary) = 0x754C (hex) = 30028 (decimal) Result: rune(30028) = '界'
Step 4: Allocate rune array
codeCount needed: 9 runes (scanned entire string) Allocate: 9 × 4 bytes = 36 bytes Memory: [????][????][????][????][????][????][????][????][????] 0 1 2 3 4 5 6 7 8 ← rune indices
Step 5: Fill rune array
code[72][101][108][108][111][44][32][19990][30028][33] H e l l o , space 世 界 !
UTF-8 Encoding Reference
1-byte sequences (ASCII: 0-127):
code0xxxxxxx (0x00-0x7F) Example: 'H' = 01001000 = 72
2-byte sequences (128-2047):
code110xxxxx 10xxxxxx (0xC0-0xDF, 0x80-0xBF) Example: 'é' = 11000011 10101001 = 0xC3 0xA9 = é (233)
3-byte sequences (2048-65535):
code1110xxxx 10xxxxxx 10xxxxxx (0xE0-0xEF, 0x80-0xBF, 0x80-0xBF) Example: '世' = 11100100 10111000 10010110 = 19990
4-byte sequences (65536-1114111):
code11110xxx 10xxxxxx 10xxxxxx 10xxxxxx Example: '𝄞' (musical symbol) = 0xF0 0x9D 0x84 0x9E = 119070
Why Rune Conversion Matters
Problem with string indexing:
gos := "世界" // WRONG: Indexing gives bytes, not characters fmt.Printf("%c\n", s[0]) // Prints '�' (invalid, just first byte of '世') fmt.Printf("%c\n", s[3]) // Prints '�' (invalid, first byte of '界') // CORRECT: Convert to runes first runes := []rune(s) fmt.Printf("%c\n", runes[0]) // Prints '世' ✓ fmt.Printf("%c\n", runes[1]) // Prints '界' ✓
Visual demonstration:
codeString "世界" as bytes: Index: 0 1 2 3 4 5 Byte: [E4][B8][96][E7][95][8C] └─世──┘ └─界──┘ s[0] = 0xE4 (just one byte, incomplete!) s[3] = 0xE7 (just one byte, incomplete!) String "世界" as runes: Index: 0 1 Rune: [世] [界] 19990 30028 runes[0] = 19990 (complete character ✓) runes[1] = 30028 (complete character ✓)
Runtime Performance
Time Complexity: O(n) where n = number of bytes
codeMust scan every byte to find character boundaries Average performance: - ASCII text (1 byte/char): ~100 MB/s - Mixed text: ~50-80 MB/s (more decoding work) - Heavy Unicode: ~30-50 MB/s (all 3-4 byte sequences)
Space Complexity:
codeWorst case (all ASCII): 4× more memory (1 byte → 4 bytes per character) Best case (all 4-byte UTF-8): 1× (4 bytes → 4 bytes per character) Typical (mixed): 2-3× more memory Example: "Hello" (ASCII): - String: 5 bytes - []rune: 20 bytes (4× larger) "世界" (Chinese): - String: 6 bytes - []rune: 8 bytes (1.33× larger)
Compiler and Runtime Behavior
What the runtime does:
gorunes := []rune(s) // Runtime (simplified): // 1. First pass: Count runes and calculate needed space runeCount := 0 for i := 0; i < len(s); { _, size := utf8.DecodeRuneInString(s[i:]) runeCount++ i += size } // 2. Allocate rune array runeArray := make([]rune, runeCount) // 3. Second pass: Decode
Was this helpful?