type-system
1w ago

Explain type conversion in golang

15 views • 0 upvotes

Table of Contents

  1. Numeric Conversions
  2. String Conversions
  3. Slice and Array Conversions
  4. Pointer Conversions
  5. Interface Conversions
  6. Struct Conversions
  7. Channel Conversions
  8. Map Conversions
  9. Function Conversions
  10. 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

go
var 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
code
-1
is stored in different integer sizes:
code
int8(-1):    [11111111]                           (1 byte)
int16(-1):   [11111111][11111111]                 (2 bytes)
int32(-1):   [11111111][11111111][11111111][11111111]  (4 bytes)
Each box
code
[]
represents one byte (8 bits). Notice how the pattern of 1s extends as the size increases.

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:
  1. Look at the leftmost bit (the sign bit)
  2. If it's 1 (negative number), fill all new bits with 1s
  3. If it's 0 (positive number), fill all new bits with 0s
Example: int8(127) to int16
code
int8:  [01111111]              (127 in binary)
        ↓ sign bit is 0, so extend with 0s
int16: [00000000][01111111]    (still 127)
Example: int8(-1) to int16
code
int8:  [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:
code
uint8:  [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
code
int16(300):  [00000001][00101100]    (300 in binary)
              ^^^^^^^^  ^^^^^^^^
              throw     keep only
              away      these bits
                          ↓
int8:                   [00101100]    (44 in decimal!)
This is why converting
code
300
(which needs 9 bits) to
code
int8
(only 8 bits) gives you
code
44
- you lose the leftmost bit!

Compiler 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
    code
    MOVSX
    (move with sign extension) or
    code
    MOVZX
    (move with zero extension)
  • 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
code
42
as
code
42.0
- same value, different representation.

Code Example

go
var 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
  1. Start with integer:
    code
    42
    in binary is
    code
    101010
  2. Normalize it:
    code
    1.01010 × 2^5
    (move decimal point)
  3. Store:
    • Sign:
      code
      0
      (positive)
    • Exponent:
      code
      5 + 127 = 132
      =
      code
      10000100
      (biased by 127)
    • Mantissa:
      code
      01010000000000000000000
      (drop the leading 1)
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:
code
Integer:  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

code
Integer 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
    code
    CVTSI2SS
    on x86)
  • 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

go
var 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:
  1. Decode the float (IEEE 754):
    • Extract mantissa and exponent
    • Calculate actual value: 42.7
  2. Isolate integer part:
    code
    42.7
    ↓ truncate
    42
    
  3. Store as integer:
    • Convert 42 to binary:
      code
      101010
    • Pad with zeros:
      code
      00000000000000000000000000101010

Overflow Behavior

What happens if the float is too large for the integer type?
go
var 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:
go
if 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
    code
    CVTTSS2SI
    (convert with truncation)
  • The "TT" in the instruction means "truncate toward zero"
  • Very fast operation

Visual Guide: Different Rounding Methods

go
value := 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

go
var 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):
code
3.1415927...
↑↑↑↑↑↑↑
reliable digits

Rest is either:
- Rounded
- Zero-padded
- Garbage
Float64 precision (~15 decimal digits):
code
3.141592653589793...
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
reliable digits

How Float64 → Float32 Conversion Works

Step 1: Extract components from float64
code
Original: 3.14159265358979323846

Float64 format:
[0][10000000000][1001001000011111101101010100010001000010110100011000]
     exponent          mantissa (52 bits)
Step 2: Round mantissa to 23 bits
code
Original 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
code
Original bits: ...0101001[1]1000...
                         ↑ next bit = 1, more bits follow
                         Round UP
Result:        ...0101010
Example 2: Exact tie (0.5)
code
Original 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

go
original := 3.14159265358979323846

// In memory (simplified):
Float64: 3.14159265358979323846
         ↑              ↑
         |              extra precision
         |
Float32: 3.1415927
         ↑       ↑
         |       approximation starts here
         exact
When you convert back to float64:
go
f32 := 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
code
Exponent: 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
    code
    CVTSD2SS
    (convert scalar double to single)
  • float32 → float64: Uses
    code
    CVTSS2SD
    (convert scalar single to double)
  • 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

OperationPrecisionNotes
float64 → float6415-17 digitsNo loss
float32 → float646-9 digitsExact value, but no added precision
float64 → float326-9 digitsLoses precision, rounds
float32 → float64 → float326-9 digitsLoses 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
code
3 + 4i
). In Go,
code
complex64
uses two
code
float32
values, and
code
complex128
uses two
code
float64
values. Converting between them is like converting two separate floats at once.

Code 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

code
    Imaginary 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:
code
Original 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:
code
Original 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:
code
Complex 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:
code
Input:    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

go
c1 := 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:
code
    6i |
       |        • 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

TypeReal PrecisionImaginary PrecisionTotal 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:
    code
    real()
    and
    code
    imag()
    built-in functions
  • Construction:
    code
    complex(r, i)
    built-in function
  • 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:
code
bool 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:
go
bytesRead, 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
go
age := 25
hasLicense := true

// Multiple conditions
canDrive := (age >= 18) && hasLicense
needsSupervision := (age < 18) || !hasLicense
Pattern 3: Boolean algebra
go
a := 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:
code
Memory 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++:
c
int x = 0;
if (x) { }  // Legal: 0 is false, anything else is true
Python:
python
x = 0
if x:  # Legal: 0, None, "", [] are all false
    pass
Go:
go
var x int = 0
if x != 0 {  // Must be explicit!
    // ...
}

Why This Design Choice?

Go's philosophy: Explicit is better than implicit
  1. Prevents bugs:
    go
    // Prevents writing:
    if err {  // ERROR: err is an error, not a bool
        // ...
    }
    
    // Forces correct:
    if err != nil {
        // ...
    }
    
  2. 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?"
    }
    
  3. 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
    > 0
    ,
    code
    == 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 (
code
[]byte
) are mutable (you can change any byte). Converting a string to
code
[]byte
creates a new, separate copy that you can modify without affecting the original string.
Think of it like photocopying a book: the original stays the same, and you can write notes on your copy.

Code Example

go
s := "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:
code
String "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:
code
Original 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
code
String: "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
code
Allocate 13 bytes on the heap:
[??][??][??][??][??][??][??][??][??][??][??][??][??]
 ↑ uninitialized memory
Step 3: Copy each byte
code
Source (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
code
Slice header:
┌─────────────────┬────────┬──────────┐
│ pointer to data │ len=13 │ cap=13   │
└─────────────────┴────────┴──────────┘

Why This Takes Time and Space

Time Complexity: O(n) where n = number of bytes
code
For 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)
code
Original 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:
go
s := "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:
code
1. 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

code
Original 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

go
b := []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:
code
Slice header (24 bytes):
┌──────────────┬─────────┬──────────┐
│  pointer     │  len=5  │  cap=5   │
└──────┬───────┴─────────┴──────────┘
       │
       ↓
Mutable byte array:
[72][101][108][108][111]
 'H' 'e'  'l'  'l'  'o'
After string(b):
code
Original []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
code
string([]byte{228, 184, 150})
(the UTF-8 bytes for '世'):
Step 1: Examine input
code
Input slice:
[228][184][150]
  ↓    ↓    ↓
0xE4 0xB8 0x96 (in hexadecimal)

These bytes form the UTF-8 encoding of '世'
Step 2: Allocate string memory
code
Allocate 3 bytes for string data:
[??][??][??]
 ↑ uninitialized
Step 3: Copy bytes
code
From slice: [228][184][150]
             ↓    ↓    ↓
To string:  [228][184][150]
Step 4: Create string header
code
String header:
┌──────────────────────┬────────┐
│ pointer to new data  │ len=3  │
└──────────────────────┴────────┘
Step 5: NO UTF-8 validation
code
Important: 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:
code
With 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:
code
Sometimes you want invalid strings:
- Reading binary data
- Partial UTF-8 sequences during streaming
- Custom encodings

Compiler and Runtime Behavior

What happens at runtime:
go
b := []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):
code
May allocate on stack:
┌─────────────┐
│   Stack     │
│ [string data] ← fast allocation
└─────────────┘
Large strings (> 32 bytes typically):
code
Allocates on heap:
┌─────────────┐
│    Heap     │
│ [string data] ← slower, but can grow
└─────────────┘

Stack just holds the header:
┌──────────┐
│  Stack   │
│  header ─┼─→ heap data
└──────────┘

Performance Characteristics

Time Complexity: O(n)
code
For 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)
code
Input:  []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
go
var buffer bytes.Buffer
buffer.WriteString("Hello, ")
buffer.WriteString("World")

// Convert buffer to string (makes a copy)
result := buffer.String()
Pattern 3: Reading file data
go
data, 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:
go
b := []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:
go
import "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
code
[]rune
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.
Think 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

go
s := "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:
code
String "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):
code
Slice 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
code
Bytes: [0xE4][0xB8][0x96][0xE7][0x95][0x8C]
        └──────世──────┘ └──────界──────┘
Step 2: Decode first character '世'
code
First 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 '界'
code
Bytes: [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
code
Count 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):
code
0xxxxxxx  (0x00-0x7F)
Example: 'H' = 01001000 = 72
2-byte sequences (128-2047):
code
110xxxxx 10xxxxxx  (0xC0-0xDF, 0x80-0xBF)
Example: 'é' = 11000011 10101001 = 0xC3 0xA9 = é (233)
3-byte sequences (2048-65535):
code
1110xxxx 10xxxxxx 10xxxxxx  (0xE0-0xEF, 0x80-0xBF, 0x80-0xBF)
Example: '世' = 11100100 10111000 10010110 = 19990
4-byte sequences (65536-1114111):
code
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
Example: '𝄞' (musical symbol) = 0xF0 0x9D 0x84 0x9E = 119070

Why Rune Conversion Matters

Problem with string indexing:
go
s := "世界"

// 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:
code
String "世界" 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
code
Must 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:
code
Worst 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:
go
runes := []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?

Difficulty & Status

medium
Lvl. 4
Community Verified

Related Topics

errorerror-handling
Progress: 50%
Answered by: shubham vyasPrev TopicNext Topic