Skip to main content

Command Palette

Search for a command to run...

Language Switcher Cheatsheet: Java, JavaScript, Python, Go

Updated
57 min read
Language Switcher Cheatsheet: Java, JavaScript, Python, Go

Introduction

I've been a backend developer since 2008, spending most of my career writing Java. Java is how I think - when I approach a problem, my brain defaults to classes, interfaces, and explicit types. Over the years, I've jumped between platforms and languages - Python, JavaScript, and now Go - each time experiencing the disorienting feeling of context switching between different philosophies and paradigms.

After spending time in Python, I found myself loving some aspects (the simplicity, the readability) while hating others (the lack of compile-time safety, the "we're all consenting adults" approach that sometimes feels too permissive). JavaScript is elegant in its own way, but it often feels like a toy to me - too loose, too flexible, too willing to let you shoot yourself in the foot.

Now, as I'm learning Go, I'm surprised to find it feels more verbose than Java in some ways, despite Go's reputation for simplicity. Things that feel natural in Java require a different mental model in Go.

Everything here is opinion-based. These are my observations from years of switching between languages, and your experience might differ. In the end, I strongly believe the best language is the one you know best - but understanding why languages differ helps you switch contexts faster.

This cheatsheet is my attempt to document the "why" behind the differences - not just what the syntax is, but why each language chose to do things differently. If you, like me, think in one language and need to work in another, this guide is for you.


Variables & Initialization

How to declare variables

WhatJavaJavaScriptPythonGo
Basic variableString name = "John";let name = "John";name = "John"name := "John"
With explicit typeString name = "John";N/A (no types)name: str = "John" (optional)var name string = "John"
Constant (can't change)final String name = "John";const name = "John";NAME = "John" (by convention)const name = "John"
Global constantstatic final String NAME = "John";const NAME = "John";NAME = "John"const NAME = "John"

Why they're different

Java - final vs constants

  • final means you can only assign once
  • Inside a method: final String name = "John"; - you can't reassign name
  • For a true constant (like MAX_SIZE): use static final in a class
  • Java doesn't have a simple "const" keyword
public class Config {
    static final int MAX_SIZE = 100;  // Global constant

    void example() {
        final int count = 5;  // Local constant
        // count = 10;  // Error! Can't reassign
    }
}

JavaScript - const, let, and var

  • const - can't reassign (use this by default)
  • let - can reassign (use when you need to change the value)
  • var - old way, don't use (has confusing scoping rules)
const name = "John";  // Can't change
// name = "Jane";  // Error!

let age = 30;  // Can change
age = 31;  // OK

const person = { name: "John" };
person.name = "Jane";  // OK! const prevents reassigning 'person', not changing its contents

Python - No constants, just convention

  • Python has no way to prevent reassignment
  • Use UPPERCASE names to show "this shouldn't change"
  • Type hints are completely optional (just for documentation/tools)
  • Python philosophy: "We're all consenting adults here"
    • Python trusts you to follow conventions instead of enforcing them
    • Gives you freedom and responsibility
    • If you see MAX_SIZE, you know not to change it - no language enforcement needed
# Convention: UPPERCASE means constant
MAX_SIZE = 100

# Type hints (optional, not enforced)
name: str = "John"
age: int = 30

# Python doesn't stop you from doing this:
MAX_SIZE = 200  # No error, but you shouldn't do this!

Go - const vs :=

  • := is shorthand for declaring and initializing (only inside functions)
  • const creates a true constant
  • Go is strict: you must use every variable you declare
func example() {
    name := "John"  // Shorthand: declare + assign
    var age int = 30  // Long form
    const MAX_SIZE = 100  // Constant

    // name := "Jane"  // Error! := only for new variables
    name = "Jane"  // OK, reassigning existing variable
}

const PI = 3.14159  // Package-level constant

Strings

How strings work

WhatJavaJavaScriptPythonGo
Create stringString name = "Hello";let name = "Hello";name = "Hello"name := "Hello"
Concatenation"Hello" + " " + "World"`Hello ${name}` or "Hello" + namef"Hello {name}" or "Hello" + name"Hello " + name
MultilineText blocks """...""" (Java 15+)Template literals `...` Triple quotes """..."""Backticks `...`
Immutable?YesYesYesYes
Character at indexs.charAt(0)s[0] or s.charAt(0)s[0]s[0] (returns byte!)

Why they're different

All four languages have immutable strings - once created, you can't change them. Any "modification" creates a new string.

Java - String vs StringBuilder

// Strings are immutable
String name = "Alice";
name = name + " Smith";  // Creates a NEW string

// String concatenation in loops is slow (creates many objects)
String result = "";
for (int i = 0; i < 1000; i++) {
    result = result + i;  // BAD - creates 1000 string objects!
}

// Use StringBuilder for performance
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result = sb.toString();

// String formatting
String message = String.format("Hello %s, age %d", name, 30);

// Multiline strings (Java 15+)
String text = """
    Line 1
    Line 2
    Line 3
    """;

JavaScript - Template literals

// Strings are immutable
let name = "Alice";
name = name + " Smith";  // Creates a new string

// Template literals (modern way)
let message = `Hello ${name}, age ${30}`;

// Multiline with template literals
let text = `
    Line 1
    Line 2
    Line 3
`;

// Old-style concatenation
let message2 = "Hello " + name + ", age " + 30;

Python - f-strings

# Strings are immutable
name = "Alice"
name = name + " Smith"  # Creates a new string

# f-strings (Python 3.6+, modern way)
message = f"Hello {name}, age {30}"

# Older formatting
message = "Hello {}, age {}".format(name, 30)
message = "Hello %s, age %d" % (name, 30)

# Multiline strings
text = """
Line 1
Line 2
Line 3
"""

# String concatenation in loops is OK (Python optimizes this)
result = ""
for i in range(1000):
    result += str(i)  # Python handles this efficiently

Go - Strings are byte slices (UTF-8)

Go strings are different - they're immutable byte slices encoded in UTF-8:

// Strings are immutable
name := "Alice"
name = name + " Smith"  // Creates a new string

// String concatenation
message := "Hello " + name

// fmt.Sprintf for formatting
message := fmt.Sprintf("Hello %s, age %d", name, 30)

// Multiline strings (raw string literals with backticks)
text := `
Line 1
Line 2
Line 3
`

// IMPORTANT: Indexing gives you BYTES, not characters!
s := "Hello"
b := s[0]  // b is a byte (uint8), value 72 ('H')

// For Unicode characters, use runes
s := "Hello 世界"
runes := []rune(s)  // Convert to slice of runes (Unicode code points)
fmt.Println(runes[6])  // 世

// Iterating over string with range gives you runes
for i, r := range "Hello 世界" {
    fmt.Printf("%d: %c\n", i, r)  // Prints Unicode characters correctly
}

// String concatenation in loops - use strings.Builder
var sb strings.Builder
for i := 0; i < 1000; i++ {
    sb.WriteString(fmt.Sprintf("%d", i))
}
result := sb.String()

The Go difference: Bytes vs Runes

Go is explicit about Unicode:

  • string: Immutable sequence of bytes (UTF-8 encoded)
  • byte (uint8): Single byte
  • rune (int32): Unicode code point (a character)
s := "Hello 世界"

// Length in bytes
fmt.Println(len(s))  // 12 (not 8!) - "世界" is 6 bytes in UTF-8

// Indexing returns a byte
fmt.Println(s[0])  // 72 (byte value of 'H')

// To work with characters, convert to []rune
runes := []rune(s)
fmt.Println(len(runes))  // 8 (correct character count)
fmt.Println(runes[6])    // 19990 (Unicode code point for '世')

// Range over string automatically decodes UTF-8 to runes
for i, r := range s {
    fmt.Printf("Position %d: %c\n", i, r)
}
// Output:
// Position 0: H
// Position 1: e
// Position 2: l
// Position 3: l
// Position 4: o
// Position 5:
// Position 6: 世
// Position 9: 界

Why Go does this:

  • Explicit: You can see when you're working with bytes vs characters
  • Performance: Strings are just byte slices internally (efficient)
  • Correctness: Forces you to think about Unicode properly
  • Other languages: Hide these details (convenient but can cause bugs with non-ASCII text)

Common Go gotcha:

s := "café"
fmt.Println(len(s))     // 5, not 4! (é is 2 bytes in UTF-8)
fmt.Println(s[3])       // 195 (first byte of é), not 'é'!
fmt.Println([]rune(s))  // [99 97 102 233] - correct 4 characters

In Java/JavaScript/Python, strings handle Unicode automatically (characters are the abstraction). In Go, you must explicitly choose bytes or runes.


Control Flow: if, for, while

If statements

WhatJavaJavaScriptPythonGo
Basic ifif (age > 18) { }if (age > 18) { }if age > 18:if age > 18 { }
If-elseif (x) { } else { }if (x) { } else { }if x:\n ...\nelse:\n ...if x { } else { }
Ternaryx > 0 ? "pos" : "neg"x > 0 ? "pos" : "neg""pos" if x > 0 else "neg"No ternary operator

Why they're different

Java - Parentheses required

if (age > 18) {
    System.out.println("Adult");
} else if (age > 12) {
    System.out.println("Teen");
} else {
    System.out.println("Child");
}

// Ternary operator
String status = age > 18 ? "Adult" : "Minor";

JavaScript - Same syntax as Java, but with "truthy/falsy" values

JavaScript's if statements look like Java syntactically, but there's a key difference: JavaScript doesn't require a boolean expression. Instead, it converts any value to true or false using the concept of "truthy" and "falsy" values. This means you can write if (name) instead of if (name !== null && name !== ""), which is convenient but can lead to unexpected bugs when values like 0 or "" are valid data.

if (age > 18) {
    console.log("Adult");
} else if (age > 12) {
    console.log("Teen");
} else {
    console.log("Child");
}

// Ternary operator
const status = age > 18 ? "Adult" : "Minor";

// JavaScript's "truthy" and "falsy" concept
if (name) {  // Checks if name is truthy (not just === true)
    console.log("Name exists");
}

// These are ALL falsy (evaluate to false in conditions):
// - false
// - 0
// - "" (empty string)
// - null
// - undefined
// - NaN

// Everything else is truthy, including:
// - "0" (string with zero)
// - "false" (string)
// - [] (empty array)
// - {} (empty object)

// This can be convenient but also dangerous:
if (count) {  // Oops! Fails when count is 0
    console.log("Has items");
}

// Better to be explicit:
if (count > 0) {
    console.log("Has items");
}

Python - No parentheses, indentation matters

if age > 18:
    print("Adult")
elif age > 12:  # "elif", not "else if"
    print("Teen")
else:
    print("Child")

# Ternary (different syntax)
status = "Adult" if age > 18 else "Minor"

Go - No parentheses, but braces required

if age > 18 {
    fmt.Println("Adult")
} else if age > 12 {
    fmt.Println("Teen")
} else {
    fmt.Println("Child")
}

// No ternary operator! Must use if-else
var status string
if age > 18 {
    status = "Adult"
} else {
    status = "Minor"
}

// Go special: if with initialization
if err := doSomething(); err != nil {
    // err only exists in this block
    fmt.Println(err)
}

For loops

WhatJavaJavaScriptPythonGo
Traditional forfor (int i = 0; i < 10; i++)for (let i = 0; i < 10; i++)for i in range(10):for i := 0; i < 10; i++
For-eachfor (String s : list)for (const s of list)for s in list:for _, s := range list
While-stylewhile (condition)while (condition)while condition:for condition { }

Why they're different

Java - Traditional C-style

// Traditional for
for (int i = 0; i < 10; i++) {
    System.out.println(i);
}

// For-each (enhanced for loop)
List<String> names = List.of("Alice", "Bob", "Charlie");
for (String name : names) {
    System.out.println(name);
}

// Stream forEach (Java 8+, functional style)
names.forEach(name -> System.out.println(name));

// Or with method reference
names.forEach(System.out::println);

// While loop
int i = 0;
while (i < 10) {
    System.out.println(i);
    i++;
}

JavaScript - Multiple ways

// Traditional for
for (let i = 0; i < 10; i++) {
    console.log(i);
}

// for...of (modern, for values)
const names = ["Alice", "Bob", "Charlie"];
for (const name of names) {
    console.log(name);
}

// for...in (for keys/indices - avoid for arrays)
for (const index in names) {
    console.log(index);  // Prints "0", "1", "2"
}

// forEach (functional style)
names.forEach(name => console.log(name));

// While loop
let i = 0;
while (i < 10) {
    console.log(i);
    i++;
}

Python - Simple and consistent

# Range-based for (most common)
for i in range(10):
    print(i)

# For-each
names = ["Alice", "Bob", "Charlie"]
for name in names:
    print(name)

# With index
for i, name in enumerate(names):
    print(f"{i}: {name}")

# While loop
i = 0
while i < 10:
    print(i)
    i += 1

Go - Only "for", but flexible

Go only has ONE loop keyword: for. It replaces for, while, and for-each:

// Traditional for
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

// While-style (no "while" keyword!)
i := 0
for i < 10 {
    fmt.Println(i)
    i++
}

// Infinite loop
for {
    // break to exit
}

// Range over slice
names := []string{"Alice", "Bob", "Charlie"}
for i, name := range names {
    fmt.Printf("%d: %s\n", i, name)
}

// Just the value (ignore index with _)
for _, name := range names {
    fmt.Println(name)
}

// Just the index
for i := range names {
    fmt.Println(i)
}

// Range over map
ages := map[string]int{"Alice": 30, "Bob": 25}
for name, age := range ages {
    fmt.Printf("%s: %d\n", name, age)
}

Lists, Maps, and Collections

How to create and use them

Lists (ordered collections)

WhatJavaJavaScriptPythonGo
Create empty listList<String> names = new ArrayList<>();const names = [];names = []names := []string{}
Create with valuesList<String> names = Arrays.asList("Alice", "Bob");const names = ["Alice", "Bob"];names = ["Alice", "Bob"]names := []string{"Alice", "Bob"}
Add itemnames.add("Charlie");names.push("Charlie");names.append("Charlie")names = append(names, "Charlie")
Get itemnames.get(0)names[0]names[0]names[0]
Lengthnames.size()names.lengthlen(names)len(names)

Maps/Dictionaries (key-value pairs)

WhatJavaJavaScriptPythonGo
Create empty mapMap<String, Integer> ages = new HashMap<>();const ages = {}; or new Map()ages = {}ages := make(map[string]int)
Create with valuesSee belowconst ages = {Alice: 30, Bob: 25};ages = {"Alice": 30, "Bob": 25}ages := map[string]int{"Alice": 30, "Bob": 25}
Add/updateages.put("Alice", 30);ages.Alice = 30; or ages["Alice"] = 30;ages["Alice"] = 30ages["Alice"] = 30
Get valueages.get("Alice")ages.Alice or ages["Alice"]ages["Alice"]ages["Alice"]
Check if key existsages.containsKey("Alice")"Alice" in ages or ages.has("Alice")"Alice" in ages_, exists := ages["Alice"]

Why they're different

Java - Arrays vs Lists

Java has two main ways to work with collections:

Arrays (fixed size):

String[] names = new String[3];  // Fixed size: 3
names[0] = "Alice";
names[1] = "Bob";

// Or initialize with values
String[] names = {"Alice", "Bob", "Charlie"};

Lists (dynamic size, more flexible):

// Modern way (Java 10+ with var)
var names = new ArrayList<String>();
names.add("Alice");
names.add("Bob");

// Or with initial values
var names = List.of("Alice", "Bob");  // Java 9+, immutable
var names = new ArrayList<>(List.of("Alice", "Bob"));  // Mutable copy

// Pre-Java 10 (verbose)
List<String> names = new ArrayList<>();

Maps in Java:

// Modern way
var ages = new HashMap<String, Integer>();
ages.put("Alice", 30);
ages.put("Bob", 25);

// With initial values (Java 9+)
var ages = Map.of("Alice", 30, "Bob", 25);  // Immutable

// Pre-Java 10
Map<String, Integer> ages = new HashMap<>();

JavaScript - Arrays and Objects (or Map)

JavaScript uses arrays for lists and objects for key-value pairs:

// Arrays (lists)
const names = ["Alice", "Bob"];
names.push("Charlie");  // Add to end
names[0];  // "Alice"

// Objects as maps (most common)
const ages = {
    Alice: 30,
    Bob: 25
};
ages.Charlie = 35;  // Add new key

// Real Map (less common, but better for non-string keys)
const ages = new Map();
ages.set("Alice", 30);
ages.get("Alice");  // 30

Python - Lists and Dicts

Python keeps it simple - lists and dictionaries:

# Lists
names = ["Alice", "Bob"]
names.append("Charlie")
names[0]  # "Alice"

# Dictionaries (dicts)
ages = {"Alice": 30, "Bob": 25}
ages["Charlie"] = 35  # Add new key
ages["Alice"]  # 30

# Check if key exists
if "Alice" in ages:
    print(ages["Alice"])

Go - Slices and Maps, with make

Go has slices (dynamic arrays) and maps:

// Slices (like dynamic arrays)
names := []string{"Alice", "Bob"}  // Create with values
names = append(names, "Charlie")  // Must reassign!
names[0]  // "Alice"

// Or with make (preallocate capacity)
names := make([]string, 0, 10)  // length 0, capacity 10

// Maps
ages := map[string]int{"Alice": 30, "Bob": 25}
ages["Charlie"] = 35

// Or with make
ages := make(map[string]int)
ages["Alice"] = 30

// Check if key exists
age, exists := ages["Alice"]
if exists {
    fmt.Println(age)
}

What is make()?

make() is Go's built-in function for creating slices, maps, and channels. You need it when:

For slices:

  • You want to preallocate capacity (performance optimization)
  • make([]string, length, capacity)
    • length: current size (how many elements exist now)
    • capacity: space allocated (how much it can grow before reallocating)
// Empty slice with capacity 100 - efficient if you know you'll add ~100 items
names := make([]string, 0, 100)  // length=0, capacity=100

// Slice with 5 elements initialized to zero values
numbers := make([]int, 5)  // [0, 0, 0, 0, 0]

// Without make (also valid)
names := []string{}  // Empty slice, capacity 0

For maps:

  • You must use make() to create an empty map
  • Maps can't be created with {} like slices
ages := make(map[string]int)  // Empty map, ready to use

// This would panic:
// var ages map[string]int  // nil map, can't add to it!
// ages["Alice"] = 30  // PANIC!

Why make() exists:

  • Slices and maps need memory allocation
  • make() allocates and initializes the underlying data structure
  • It's explicit: you can see when you're allocating memory

make() vs new() in Go:

Go has two allocation functions that confuse beginners:

Featuremake()new()
What it returnsInitialized valuePointer to zeroed memory
Use forSlices, maps, channels onlyAny type
Returns ready to use?YesNo (need to initialize)
Common usageVery commonRare (just use &T{})
// make() - for slices, maps, channels - RETURNS READY-TO-USE VALUE
myMap := make(map[string]int)  // Returns initialized map
myMap["key"] = 1  // Works immediately

// new() - for any type - RETURNS POINTER TO ZEROED MEMORY
myMap := new(map[string]int)  // Returns *map[string]int (pointer to nil map)
// *myMap["key"] = 1  // PANIC! The map is nil, not initialized

// Better alternative to new() - just use & with composite literal
type Person struct {
    Name string
    Age  int
}

// Don't do this:
p := new(Person)  // Returns *Person with zero values
p.Name = "Alice"

// Do this instead:
p := &Person{Name: "Alice", Age: 30}  // Clearer and more idiomatic

Summary:

  • make(): Use for slices, maps, channels - returns ready-to-use value
  • new(): Almost never use - just use &T{} instead
  • Java comparison: make() is like new ArrayList<>() - allocates AND initializes
  • new() is like Java's new - but in Go you rarely need it

What are slices?

In Go, a slice is like a dynamic array:

  • Can grow and shrink
  • append() adds items (you must reassign: names = append(names, "Charlie"))
  • More flexible than fixed arrays

Why Go uses slices:

  • Performance: A slice is just a pointer to an underlying array, a length, and a capacity
  • Memory efficient: Multiple slices can share the same underlying array
  • Explicit control: You can see when memory is being allocated (when you append() beyond capacity)
  • Simplicity: One simple concept instead of ArrayList, LinkedList, Vector, etc.
  • Go philosophy: "There should be one obvious way to do it" - slices are that way

Example of why it's efficient:

// Creating a slice from an array - no copying!
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4]  // [2, 3, 4] - just a view, not a copy

// Preallocating capacity avoids multiple allocations
names := make([]string, 0, 100)  // Space for 100, but length 0
// Now you can append 100 items without reallocating

In Java, you don't use the term "slice", but:

  • Use ArrayList for dynamic arrays
  • Use arrays [] for fixed-size
  • ArrayList hides the complexity (automatic resizing), but you pay for it with less control

In JavaScript, arrays are already dynamic (like slices)

  • JavaScript hides all the complexity
  • You don't think about capacity or reallocation

In Python, lists are already dynamic (like slices)

  • Python hides all the complexity
  • Python philosophy: "We're all consenting adults here" - Python trusts you to use features responsibly
  • Python gives you power without forcing you to manage low-level details

Iterating over collections

Java - Traditional and enhanced for loop

var names = List.of("Alice", "Bob", "Charlie");

// Traditional for loop
for (int i = 0; i < names.size(); i++) {
    System.out.println(names.get(i));
}

// Enhanced for loop (for-each)
for (String name : names) {
    System.out.println(name);
}

// Modern way - streams (Java 8+)
names.forEach(name -> System.out.println(name));

JavaScript - for, for...of, forEach

const names = ["Alice", "Bob", "Charlie"];

// Traditional for loop
for (let i = 0; i < names.length; i++) {
    console.log(names[i]);
}

// for...of (modern, recommended)
for (const name of names) {
    console.log(name);
}

// forEach method
names.forEach(name => console.log(name));

Python - for loop

names = ["Alice", "Bob", "Charlie"]

# Standard way
for name in names:
    print(name)

# With index
for i, name in enumerate(names):
    print(f"{i}: {name}")

Go - range

names := []string{"Alice", "Bob", "Charlie"}

// With index and value
for i, name := range names {
    fmt.Printf("%d: %s\n", i, name)
}

// Just value (ignore index with _)
for _, name := range names {
    fmt.Println(name)
}

// Just index
for i := range names {
    fmt.Println(i)
}

Data Structures: Tuples, Records, and Structs

Quick comparison

WhatJavaJavaScriptPythonGo
Simple data holderRecord (Java 14+)Object literalTuple or NamedTupleStruct
Syntaxrecord Point(int x, int y){x: 3, y: 4}(3, 4) or Point(3, 4)type Point struct { X, Y int }
Immutable?Yes (records are)No (unless frozen)Yes (tuples), No (dataclasses)No (but can't reassign struct)

Why they exist

All languages need a way to group related data without writing a full class. Each language has different solutions:

Java - Records (Java 14+)

Before records, you needed a lot of boilerplate:

// Old way - verbose
public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }
    public int y() { return y; }

    @Override
    public boolean equals(Object o) { /* ... */ }
    @Override
    public int hashCode() { /* ... */ }
    @Override
    public String toString() { /* ... */ }
}

// New way - record (one line!)
record Point(int x, int y) { }

// Usage
Point p = new Point(3, 4);
System.out.println(p.x());  // 3
System.out.println(p);      // Point[x=3, y=4] - toString() auto-generated

Records automatically give you:

  • Constructor
  • Getters (but named x(), not getX())
  • equals(), hashCode(), toString()
  • Immutable fields (can't change after creation)

JavaScript - Object literals

JavaScript is flexible - just use an object:

// Simple object literal
const point = { x: 3, y: 4 };
console.log(point.x);  // 3

// Can modify (unless you freeze it)
point.x = 10;  // OK

// Make it immutable
const frozenPoint = Object.freeze({ x: 3, y: 4 });
// frozenPoint.x = 10;  // Error in strict mode

// "Factory" function for consistency
function createPoint(x, y) {
    return { x, y };  // Shorthand property syntax
}

const p = createPoint(3, 4);

JavaScript doesn't have a dedicated "record" type - objects are flexible enough for most use cases.

Python - Tuples, NamedTuples, and Dataclasses

Python has THREE options:

# 1. Tuple - immutable, but no names
point = (3, 4)
x, y = point  # Unpack to use
print(point[0])  # 3 - access by index (not great)

# 2. NamedTuple - immutable with names
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])

p = Point(3, 4)
print(p.x)  # 3 - access by name (better!)
print(p[0])  # 3 - can still access by index
# p.x = 10  # Error - immutable

# 3. Dataclass - mutable, more features
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p = Point(3, 4)
print(p.x)  # 3
p.x = 10  # OK - mutable by default

# Can make immutable
@dataclass(frozen=True)
class Point:
    x: int
    y: int

When to use which:

  • Tuple: Simple, temporary grouping (like returning multiple values)
  • NamedTuple: Immutable data with named fields
  • Dataclass: When you need mutability or more features (default values, methods, etc.)

Go - Structs

Go only has structs - simple and consistent:

// Define struct
type Point struct {
    X int
    Y int
}

// Create instances
p1 := Point{X: 3, Y: 4}
p2 := Point{3, 4}  // Positional (less clear)

// Access fields
fmt.Println(p1.X)  // 3

// Modify (structs are mutable)
p1.X = 10

// Can't reassign the whole struct if using :=
// But can modify fields

Go structs are simple:

  • No inheritance
  • No methods on the struct definition (methods are separate)
  • Can be passed by value or by pointer
  • Uppercase = exported (public), lowercase = private

Methods and Parameters

How to declare methods

WhatJavaJavaScriptPythonGo
Basic methodvoid greet(String name) { }function greet(name) { }def greet(name):func greet(name string) { }
With return typeString getName() { return "John"; }function getName() { return "John"; }def get_name() -> str: return "John"func getName() string { return "John" }
Multiple return valuesNot supportedNot supportedreturn name, age (tuple)func getUser() (string, int) { }

How parameters work (pass by value)

All four languages pass parameters by value - but this means different things for primitives vs objects.

The rule:

  • Primitives (int, string, bool): Copy of the value is passed
  • Objects/References: Copy of the reference is passed (you can modify the object, but can't replace it)

Java

// Primitives - pass by value (copy)
void increment(int x) {
    x = x + 1;  // Only changes local copy
}

int count = 5;
increment(count);
System.out.println(count);  // Still 5 - unchanged

// Objects - pass by value of the reference
void changeName(Person p) {
    p.name = "Jane";  // Modifies the object ✓
    p = new Person("Bob");  // Only changes local reference, not original ✗
}

Person person = new Person("Alice");
changeName(person);
System.out.println(person.name);  // "Jane" - object was modified

JavaScript

// Primitives - pass by value (copy)
function increment(x) {
    x = x + 1;  // Only changes local copy
}

let count = 5;
increment(count);
console.log(count);  // Still 5 - unchanged

// Objects - pass by value of the reference
function changeName(p) {
    p.name = "Jane";  // Modifies the object ✓
    p = { name: "Bob" };  // Only changes local reference, not original ✗
}

let person = { name: "Alice" };
changeName(person);
console.log(person.name);  // "Jane" - object was modified

Python

# Primitives (immutable) - pass by value (copy)
def increment(x):
    x = x + 1  # Only changes local copy

count = 5
increment(count)
print(count)  # Still 5 - unchanged

# Objects (mutable) - pass by value of the reference
def change_name(p):
    p["name"] = "Jane"  # Modifies the object ✓
    p = {"name": "Bob"}  # Only changes local reference, not original ✗

person = {"name": "Alice"}
change_name(person)
print(person["name"])  # "Jane" - object was modified

Go

// Primitives - pass by value (copy)
func increment(x int) {
    x = x + 1  // Only changes local copy
}

count := 5
increment(count)
fmt.Println(count)  // Still 5 - unchanged

// Structs - pass by value (ENTIRE STRUCT IS COPIED!)
func changeName(p Person) {
    p.Name = "Jane"  // Only changes the COPY, not original ✗
}

person := Person{Name: "Alice"}
changeName(person)
fmt.Println(person.Name)  // Still "Alice" - unchanged!

// Pointers - pass by value of the pointer (can modify original)
func changeNamePtr(p *Person) {
    p.Name = "Jane"  // Modifies the original ✓
    p = &Person{Name: "Bob"}  // Only changes local pointer, not original ✗
}

changeNamePtr(&person)
fmt.Println(person.Name)  // "Jane" - original was modified

The Go difference: Pointers

Go is the only language here with explicit pointers (like C/C++). The other languages hide pointers from you.

When you pass a struct in Go, the entire struct is copied - but it's a shallow copy:

  • Primitive fields (int, string, bool) are copied
  • Slices, maps, pointers inside the struct are NOT deep copied - you get copies of the references
type Person struct {
    Name string
    Age  int
}

// Bad - copies entire struct
func celebrateBirthday(p Person) {
    p.Age = p.Age + 1  // Modifies copy only
}

// Good - uses pointer, no copying
func celebrateBirthday(p *Person) {
    p.Age = p.Age + 1  // Modifies original
}

person := Person{Name: "Alice", Age: 30}
celebrateBirthday(&person)  // Pass pointer with &
fmt.Println(person.Age)  // 31

Shallow copy example:

type Team struct {
    Name    string
    Members []string  // Slice is a reference
}

func addMember(t Team, member string) {
    // t is a copy, but t.Members points to the SAME underlying array
    t.Members = append(t.Members, member)  // Might modify original slice!
    t.Name = "New Name"  // Only changes the copy
}

team := Team{Name: "A-Team", Members: []string{"Alice", "Bob"}}
addMember(team, "Charlie")
fmt.Println(team.Name)     // "A-Team" - unchanged
fmt.Println(team.Members)  // Might be ["Alice", "Bob", "Charlie"] - CHANGED!
                          // (if append didn't reallocate)

This is confusing! Better to use pointers:

func addMember(t *Team, member string) {
    t.Members = append(t.Members, member)  // Clear that we're modifying
    t.Name = "New Name"  // Also modifies original
}

addMember(&team, "Charlie")  // Explicit: we're passing a reference

Why Go has explicit pointers:

FeatureJava/JavaScript/PythonGo
PointersHidden (automatic references)Explicit (* and &)
When you see itNever - objects "just work"Always - *Person or &person
ControlNo choice - objects are always referencesYou choose: value or pointer
PerformanceEverything is a reference (pointer overhead)Small structs by value (no pointer overhead), large structs by pointer
SafetyEasy to accidentally mutateMust explicitly use pointers to mutate

Performance explained:

In Java/JavaScript/Python, all objects are heap-allocated and passed by reference. Even a tiny object like a 2D point needs:

  • Heap allocation
  • Garbage collection tracking
  • Pointer dereferencing to access fields
// Java - even simple objects are on the heap
class Point {
    int x, y;  // Just 8 bytes of data
}

Point p = new Point();  // Heap allocation, GC overhead
p.x = 10;  // Pointer dereference to access field

// Java Records (Java 14+) - still heap-allocated!
record Point(int x, int y) { }
Point p = new Point(3, 4);  // Still on heap, still GC'd, still a reference

What about Java Records and Python NamedTuples?

Yes, Java has records (Java 14+) and Python has namedtuples and dataclasses, but they don't solve the performance issue:

// Java Record - less code, but STILL heap-allocated
record Point(int x, int y) { }

Point p1 = new Point(3, 4);  // Heap allocation
Point p2 = p1;  // p2 is a reference to the same object
# Python NamedTuple
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])

p1 = Point(3, 4)  # Heap allocation
p2 = p1  # p2 is a reference to the same object

# Python dataclass (similar)
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p = Point(3, 4)  # Still heap-allocated

Key difference:

  • Java Records/Python NamedTuples: Less boilerplate code, but still heap-allocated objects
  • Go structs by value: Can be stack-allocated, no heap, no GC overhead

Records and NamedTuples are about developer convenience (less code), not performance.

In Go, you can choose:

  • Small structs by value: Passed on the stack, no allocation, no GC, direct access
  • Large structs by pointer: Avoid expensive copies
// Small struct - pass by value is FASTER
type Point struct {
    X, Y int  // Just 16 bytes
}

func distance(p Point) float64 {  // Passed on stack, no allocation!
    return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}

p := Point{X: 3, Y: 4}
d := distance(p)  // No heap allocation, very fast

// Large struct - pass by pointer
type Image struct {
    Pixels [1920][1080]byte  // 2MB of data!
}

func process(img *Image) {  // Pointer: no copying 2MB
    // work with img
}

Go's escape analysis: Stack vs Heap

Go is unique - it can allocate structs on the stack instead of the heap:

func createOnStack() Point {
    p := Point{X: 3, Y: 4}
    return p  // Returned by VALUE - stays on stack
    // Automatically cleaned when function returns - no GC needed!
}

func createOnHeap() *Point {
    p := Point{X: 3, Y: 4}
    return &p  // Returns POINTER - "escapes" to heap
    // Must be GC'd later
}

The Go compiler analyzes your code ("escape analysis") and decides:

  • Stack allocation: If struct doesn't escape the function (not returned as pointer, not stored globally)
    • Faster allocation
    • Automatic cleanup when function returns
    • No GC pressure
    • Better cache locality
  • Heap allocation: If struct escapes (returned as pointer, stored in field, sent to channel)
    • Must be garbage collected
    • Slower but necessary when lifetime extends beyond function

The other languages:

  • Java: ALL objects are heap-allocated (except primitives like int)
  • JavaScript: ALL objects are heap-allocated
  • Python: ALL objects are heap-allocated

Even a tiny Point object goes on the heap in Java/JS/Python, requiring GC tracking.

Why this matters:

  • Go: Small structs (like Point, Color, Rectangle) can be stack-allocated - faster than Java/Python
  • Java/Python: ALL objects have heap allocation + GC overhead, even tiny ones
  • Go: You optimize by choosing value vs pointer, and the compiler optimizes further with escape analysis

Why Go does this:

  • Explicit: You can see when you're passing a pointer (&) or dereferencing (*)
  • Performance: You control when to copy vs reference (small structs by value, large structs by pointer)
  • Safety: Accidental mutations are harder (need explicit pointer)
  • Memory control: You know exactly what's on the stack vs heap

In Java/JavaScript/Python:

  • Objects are always passed by reference (automatic)
  • More convenient, but less explicit
  • Can lead to unexpected mutations
  • No way to pass objects "by value" (make a defensive copy if you need it)

Classes and Methods

How to define classes with methods

Java - Traditional OOP

public class Person {
    // Fields
    private String name;
    private int age;

    // Constructor
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Getter
    public String getName() {
        return name;
    }

    // Method
    public void celebrate() {
        age++;
        System.out.println(name + " is now " + age);
    }

    // Static method
    public static Person createDefault() {
        return new Person("Unknown", 0);
    }
}

// Usage
Person p = new Person("Alice", 30);
p.celebrate();

JavaScript - Prototype-based with class syntax

class Person {
    // Constructor
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    // Method
    celebrate() {
        this.age++;
        console.log(`${this.name} is now ${this.age}`);
    }

    // Getter
    get birthYear() {
        return new Date().getFullYear() - this.age;
    }

    // Static method
    static createDefault() {
        return new Person("Unknown", 0);
    }
}

// Usage
const p = new Person("Alice", 30);
p.celebrate();

// JavaScript also has prototype-based approach (pre-ES6)
function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.celebrate = function() {
    this.age++;
    console.log(this.name + " is now " + this.age);
};

Python - Simple and clean

class Person:
    # Constructor
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    # Method
    def celebrate(self):
        self.age += 1
        print(f"{self.name} is now {self.age}")

    # Property (getter)
    @property
    def birth_year(self):
        from datetime import datetime
        return datetime.now().year - self.age

    # Static method
    @staticmethod
    def create_default():
        return Person("Unknown", 0)

    # Class method
    @classmethod
    def from_birth_year(cls, name, birth_year):
        from datetime import datetime
        age = datetime.now().year - birth_year
        return cls(name, age)

# Usage
p = Person("Alice", 30)
p.celebrate()

Go - Structs with methods attached

Go doesn't have classes - it has structs and methods are defined separately:

// Define the struct (data)
type Person struct {
    Name string
    age  int  // lowercase = private
}

// Constructor function (convention)
func NewPerson(name string, age int) *Person {
    return &Person{
        Name: name,
        age:  age,
    }
}

// Method with pointer receiver (can modify the struct)
func (p *Person) Celebrate() {
    p.age++
    fmt.Printf("%s is now %d\n", p.Name, p.age)
}

// Method with value receiver (read-only)
func (p Person) GetAge() int {
    return p.age
}

// Method that returns something
func (p Person) IsAdult() bool {
    return p.age >= 18
}

// "Static" method (just a regular function)
func CreateDefaultPerson() *Person {
    return &Person{Name: "Unknown", age: 0}
}

// Usage
p := NewPerson("Alice", 30)
p.Celebrate()

The Go difference: Receiver functions

Go methods are functions with a receiver parameter:

// This method...
func (p *Person) Celebrate() {
    p.age++
}

// ...is just syntactic sugar for this:
func Celebrate(p *Person) {
    p.age++
}

// Call it like a method:
p.Celebrate()

// Or like a function (but don't do this):
(*Person).Celebrate(p)

Pointer receiver vs value receiver:

type Counter struct {
    count int
}

// Pointer receiver - modifies the original
func (c *Counter) Increment() {
    c.count++  // Changes the original
}

// Value receiver - receives a copy
func (c Counter) GetCount() int {
    return c.count  // Just reads, doesn't modify
}

// What happens?
c := Counter{count: 0}
c.Increment()  // count is now 1
c.Increment()  // count is now 2
fmt.Println(c.GetCount())  // 2

// Go is smart - you can call pointer methods on values
c.Increment()  // Go automatically does (&c).Increment()

When to use pointer vs value receivers:

Use pointer receiver (*T)Use value receiver (T)
Method modifies the structMethod only reads
Large struct (avoid copying)Small struct (a few fields)
Consistency (if any method uses *T, use it for all)Immutable behavior wanted

Go vs other languages:

// Java/JavaScript/Python: methods are INSIDE the class
class Person {
    private int age;

    void celebrate() {  // Method defined INSIDE class
        age++;
    }
}

// Go: methods are OUTSIDE the struct
type Person struct {
    age int
}

func (p *Person) celebrate() {  // Method defined OUTSIDE struct
    p.age++
}

Why Go does this:

  • Separation of data and behavior: Struct defines data, methods define behavior
  • Methods on any type: You can add methods to ANY type, even primitives:
type MyInt int

func (m MyInt) IsEven() bool {
    return m%2 == 0
}

var x MyInt = 42
fmt.Println(x.IsEven())  // true
  • No hidden coupling: All behavior is explicitly defined outside the struct
  • Interfaces are satisfied implicitly: No need to declare "implements"

Interfaces in Go:

// Define an interface
type Greeter interface {
    Greet() string
}

type Person struct {
    Name string
}

// Person automatically implements Greeter if it has a Greet() method
func (p Person) Greet() string {
    return "Hello, I'm " + p.Name
}

// Any type with Greet() satisfies the interface
func SayHello(g Greeter) {
    fmt.Println(g.Greet())
}

p := Person{Name: "Alice"}
SayHello(p)  // Works! Person implements Greeter implicitly

In Java/Python, you must explicitly declare implements or inherit. In Go, if your type has the right methods, it implements the interface - no declaration needed.

Naming conventions for visibility

Java:

  • All access via keywords
  • Naming convention: camelCase for methods/fields, PascalCase for classes

JavaScript:

  • # for private (modern)
  • Convention: _privateField (older code)
  • Naming: camelCase for everything

Python:

  • public_method() - no prefix
  • _internal_method() - single underscore (don't use outside)
  • __private_method() - double underscore (name mangling)
  • Naming: snake_case for everything

Go:

  • ExportedFunction() - uppercase
  • notExported() - lowercase
  • Naming: MixedCaps (no underscores!)

Packages and Imports

How imports work

WhatJavaJavaScriptPythonGo
Import statementimport com.example.MyClass;import { myFunc } from './module';import mymoduleimport "github.com/user/package"
Import everythingimport com.example.*;import * as lib from './module';from mymodule import *Automatic (all exported symbols)
AliasN/A (use full name)import { old as new }import module as mimport m "package"
Package declarationpackage com.example;export keywordN/A (file/directory based)package mypackage

Why they're different

Java - Package structure mirrors directory structure

// File: src/com/example/myapp/Person.java
package com.example.myapp;  // Must match directory structure!

import java.util.List;
import java.util.ArrayList;
// Or: import java.util.*;

public class Person {
    private String name;
}

Directory structure:

src/
  com/
    example/
      myapp/
        Person.java
        Main.java

Importing:

// File: src/com/example/myapp/Main.java
package com.example.myapp;

import com.example.myapp.Person;  // Import from same package (optional)
import com.example.other.Helper;   // Import from different package

public class Main {
    public static void main(String[] args) {
        Person p = new Person();
    }
}

Key points:

  • Package name MUST match directory structure
  • One public class per file (class name = file name)
  • Classes in same package can access package-private members
  • Use reverse domain naming: com.company.project

JavaScript - Module-based (ES6)

// File: person.js
export class Person {
    constructor(name) {
        this.name = name;
    }
}

export function createPerson(name) {
    return new Person(name);
}

// Default export
export default Person;

Importing:

// File: main.js

// Named imports
import { Person, createPerson } from './person.js';

// Default import
import Person from './person.js';

// Import everything
import * as PersonModule from './person.js';

// Alias
import { createPerson as makePerson } from './person.js';

const p = new Person("Alice");

Directory structure:

project/
  src/
    person.js
    main.js
    utils/
      helper.js

Key points:

  • No strict directory requirements
  • Relative paths (./, ../) or package names (lodash)
  • Can mix named and default exports
  • CommonJS (require()) still common in Node.js

Python - Module = file, Package = directory with __init__.py

# File: myapp/person.py
class Person:
    def __init__(self, name):
        self.name = name

def create_person(name):
    return Person(name)

Importing:

# File: main.py

# Import module
import myapp.person
p = myapp.person.Person("Alice")

# Import specific items
from myapp.person import Person, create_person
p = Person("Alice")

# Import with alias
import myapp.person as person_module
from myapp import person as pm

# Import everything (avoid!)
from myapp.person import *

Directory structure:

project/
  myapp/
    __init__.py      # Makes myapp a package (can be empty)
    person.py
    utils/
      __init__.py
      helper.py
  main.py

Key points:

  • __init__.py makes a directory a package (can be empty)
  • Module name = file name (without .py)
  • Python looks in sys.path for modules
  • Relative imports: from . import module (same package)

Go - Package per directory, import by path

// File: myapp/person.go
package myapp  // Package name (doesn't have to match directory!)

// Exported (uppercase)
type Person struct {
    Name string
}

// Not exported (lowercase)
func helper() {
    // ...
}

// Exported function
func NewPerson(name string) *Person {
    return &Person{Name: name}
}

Importing:

// File: main.go
package main

import (
    "fmt"                           // Standard library
    "github.com/user/project/myapp" // Your package
    m "github.com/user/other"       // Alias
)

func main() {
    p := myapp.NewPerson("Alice")
    fmt.Println(p.Name)
}

Directory structure:

project/
  go.mod                    # Module definition
  main.go
  myapp/
    person.go
    helper.go               # Same package!
  utils/
    tools.go

Key points:

  • All files in same directory = same package (must have same package declaration)
  • One package per directory (directory can have any name)
  • Import path is the directory path, not the package name
  • Exported = starts with uppercase, not exported = lowercase
  • No circular dependencies allowed!

The Go difference: Visibility and package structure

Go's unique rules:

  1. Visibility by capitalization: ```go type Person struct { Name string // Exported (public) - uppercase age int // Not exported (private) - lowercase }

func NewPerson() *Person { } // Exported func helper() { } // Not exported


2. **All files in directory share package:**
```go
// File: myapp/person.go
package myapp

type Person struct { }

// File: myapp/team.go
package myapp  // MUST be same package!

type Team struct {
    Members []Person  // Can use Person directly
}
  1. Import by path, use by package name: ```go import "github.com/user/project/myapp" // Import PATH

p := myapp.NewPerson() // Use package NAME


4. **No circular imports:**
```go
// This is NOT allowed:
// package a imports package b
// package b imports package a
// Go will refuse to compile!

Organizing code

Java:

src/main/java/
  com/
    company/
      project/
        domain/
          User.java
          Order.java
        service/
          UserService.java
        repository/
          UserRepository.java

JavaScript:

src/
  components/
    User.js
    Order.js
  services/
    userService.js
  utils/
    helpers.js

Python:

myproject/
  domain/
    __init__.py
    user.py
    order.py
  services/
    __init__.py
    user_service.py
  utils/
    __init__.py
    helpers.py

Go:

myproject/
  go.mod
  cmd/
    myapp/
      main.go
  internal/           # Only importable by this project
    domain/
      user.go
      order.go
    service/
      user.go
  pkg/                # Can be imported by others
    helpers/
      util.go

Go conventions:

  • cmd/ - Application entry points
  • internal/ - Private code (Go enforces this!)
  • pkg/ - Public libraries
  • Flat structure preferred (avoid deep nesting)

Access Control and Visibility

How visibility works

LevelJavaJavaScriptPythonGo
Public (accessible everywhere)public keywordAll exportsEverything by defaultUppercase name
Private (same class/package only)private keyword# prefix (ES2022)_ prefix (convention)Lowercase name
Protected (subclasses)protected keywordN/AN/AN/A
Package-privateNo modifier (default)N/AN/ALowercase (same package)
Module-privateN/ANot exported_ prefixN/A

Why they're different

Java - Explicit keywords

public class Person {
    public String name;           // Anyone can access
    private int age;              // Only this class
    protected String email;       // This class + subclasses
    String address;               // Package-private (default)

    public String getName() {
        return name;
    }

    private void helper() {
        // Only callable within Person class
    }
}

// In same package - can access package-private
class Helper {
    void doSomething(Person p) {
        p.address = "123 Main";  // OK - package-private
        // p.age = 30;  // Error - private
    }
}

JavaScript - Limited visibility

class Person {
    // Public by default
    name;

    // Private field (ES2022)
    #age;

    // Private method (ES2022)
    #helper() {
        // Only callable within Person class
    }

    constructor(name, age) {
        this.name = name;
        this.#age = age;
    }

    getAge() {
        return this.#age;  // Can access private within class
    }
}

const p = new Person("Alice", 30);
console.log(p.name);  // OK - public
// console.log(p.#age);  // Error - private

// Older pattern - closure for privacy
function createPerson(name, age) {
    // Private variable (closure)
    let privateAge = age;

    return {
        name: name,  // Public
        getAge() {
            return privateAge;  // Can access private
        }
    };
}

Python - Convention-based (no enforcement)

class Person:
    def __init__(self, name, age):
        self.name = name          # Public (by convention)
        self._age = age           # "Private" by convention (single _)
        self.__secret = "shh"     # Name mangling (double __)

    def get_age(self):
        return self._age

    def _helper(self):
        # "Private" method by convention
        pass

# Python doesn't enforce privacy!
p = Person("Alice", 30)
print(p.name)     # OK
print(p._age)     # "Should not" but can access
print(p._Person__secret)  # Name mangling - can still access!

Python conventions:

  • No _ prefix: Public
  • Single _ prefix: "Internal" (don't use outside, but Python won't stop you)
  • Double __ prefix: Name mangling (becomes _ClassName__attribute)

Python philosophy: "We're all consenting adults here" - no enforcement, just conventions.

Go - Capitalization determines visibility

package myapp

// Exported (public) - starts with uppercase
type Person struct {
    Name  string  // Exported field
    Email string  // Exported field
    age   int     // Not exported (private to package)
}

// Exported function
func NewPerson(name string, age int) *Person {
    return &Person{Name: name, age: age}
}

// Not exported (private to package)
func helper() {
    // ...
}

// Exported method
func (p *Person) GetAge() int {
    return p.age  // Can access private field in same package
}

// Not exported method
func (p *Person) validate() bool {
    return p.age > 0
}

From another package:

import "myproject/myapp"

p := myapp.NewPerson("Alice", 30)  // OK - exported
fmt.Println(p.Name)                // OK - exported field
// fmt.Println(p.age)              // Error - not exported
age := p.GetAge()                  // OK - exported method
// p.validate()                    // Error - not exported

Go has only two levels:

  1. Exported (uppercase) - accessible everywhere
  2. Not exported (lowercase) - only within the same package

Special: internal/ directory

myproject/
  internal/
    utils/
      helper.go  // Only importable by myproject

Go enforces that packages under internal/ can only be imported by the parent project.

Comparison table

FeatureJavaJavaScriptPythonGo
Public syntaxpublicDefault or exportDefaultUppercase
Private syntaxprivate#field_field (convention)lowercase
EnforcementCompile-timeCompile-time (for #)None (convention only)Compile-time
Package-privateDefault (no modifier)No conceptNo conceptlowercase
ProtectedprotectedNoNoNo
Levels4 (public, protected, package, private)2 (public, private)1 (all public, convention)2 (exported, not exported)

Concurrency: Async, Threads, and Channels

How concurrency works

WhatJavaJavaScriptPythonGo
Concurrency modelThreads (OS threads)Event loop (single-threaded)Threading + GIL, asyncioGoroutines (lightweight threads)
Basic unitThreadPromise/async functionthreading.Threadgoroutine
Async syntaxCompletableFutureasync/awaitasync/awaitNative (just go)
CommunicationShared memory + locksCallbacks/PromisesQueues, shared memoryChannels
Cost per unit~1MB stack (heavy)Very light~1MB stack (heavy)~2KB stack (very light)

Why they're different

Java - Traditional threads with virtual threads (Java 21+)

Java has OS threads (expensive) and now virtual threads (lightweight):

// Traditional threads (heavyweight)
Thread thread = new Thread(() -> {
    System.out.println("Running in thread");
});
thread.start();
thread.join();  // Wait for completion

// ExecutorService for thread pools
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
    // Do work
});
executor.shutdown();

// CompletableFuture for async operations
CompletableFuture.supplyAsync(() -> {
    return fetchData();
}).thenApply(data -> {
    return processData(data);
}).thenAccept(result -> {
    System.out.println(result);
});

// Virtual Threads (Java 21+) - lightweight!
Thread.startVirtualThread(() -> {
    System.out.println("Running in virtual thread");
});

// Or with ExecutorService
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
    // Each task gets its own virtual thread (cheap!)
});

Why Java has virtual threads:

  • Traditional threads are OS threads (expensive, ~1MB each)
  • Can't create millions of threads
  • Virtual threads are lightweight (like Go goroutines)
  • Managed by JVM, not OS
  • Can create millions of them
  • Makes writing concurrent code easier (no callbacks!)

JavaScript - Event loop with async/await

JavaScript is single-threaded with an event loop:

// Promises
fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error(error));

// Async/await (modern way)
async function fetchData() {
    try {
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

// Multiple concurrent operations
async function fetchMultiple() {
    // Run in parallel
    const [users, posts] = await Promise.all([
        fetch('/api/users').then(r => r.json()),
        fetch('/api/posts').then(r => r.json())
    ]);

    return { users, posts };
}

// setTimeout for delayed execution
setTimeout(() => {
    console.log("After 1 second");
}, 1000);

Why JavaScript is single-threaded:

  • Originally designed for browsers (DOM manipulation isn't thread-safe)
  • Event loop handles I/O concurrency
  • No threads = no race conditions, no locks
  • Great for I/O-heavy tasks (servers, APIs)
  • Poor for CPU-heavy tasks (can't use multiple cores)

Python - Threading with GIL, asyncio for async

Python has two models:

# Threading (limited by GIL - Global Interpreter Lock)
import threading

def worker():
    print("Running in thread")

thread = threading.Thread(target=worker)
thread.start()
thread.join()  # Wait for completion

# Multiple threads
threads = []
for i in range(10):
    t = threading.Thread(target=worker)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

# Asyncio (for I/O-bound tasks)
import asyncio

async def fetch_data():
    await asyncio.sleep(1)  # Simulated I/O
    return "Data"

async def main():
    # Run concurrently
    results = await asyncio.gather(
        fetch_data(),
        fetch_data(),
        fetch_data()
    )
    print(results)

asyncio.run(main())

# Multiprocessing (for CPU-bound tasks, bypasses GIL)
from multiprocessing import Process

def cpu_work():
    # Heavy computation
    pass

p = Process(target=cpu_work)
p.start()
p.join()

The GIL problem:

  • GIL (Global Interpreter Lock) = only one thread executes Python bytecode at a time
  • Threading is good for I/O-bound tasks (waiting on network, disk)
  • Threading is BAD for CPU-bound tasks (can't use multiple cores)
  • Use multiprocessing for CPU-bound tasks (separate processes, no GIL)
  • Use asyncio for I/O-bound tasks (efficient, single-threaded)

Go - Goroutines and channels

Go has built-in lightweight concurrency:

// Goroutine - just add 'go'
go func() {
    fmt.Println("Running in goroutine")
}()

// Wait for goroutines with WaitGroup
var wg sync.WaitGroup

for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Printf("Goroutine %d\n", id)
    }(i)
}

wg.Wait()  // Wait for all goroutines

// Channels for communication
ch := make(chan string)

// Send to channel (in goroutine)
go func() {
    ch <- "Hello from goroutine"
}()

// Receive from channel
msg := <-ch
fmt.Println(msg)

// Buffered channels
ch := make(chan int, 3)  // Can hold 3 values
ch <- 1
ch <- 2
ch <- 3
// ch <- 4  // Would block until someone reads

// Select for multiple channels
select {
case msg := <-ch1:
    fmt.Println("From ch1:", msg)
case msg := <-ch2:
    fmt.Println("From ch2:", msg)
case <-time.After(1 * time.Second):
    fmt.Println("Timeout")
}

// Worker pool pattern
jobs := make(chan int, 100)
results := make(chan int, 100)

// Start workers
for w := 0; w < 3; w++ {
    go worker(jobs, results)
}

// Send jobs
for j := 0; j < 10; j++ {
    jobs <- j
}
close(jobs)

// Collect results
for r := 0; r < 10; r++ {
    <-results
}

Why Go uses goroutines and channels:

  • Goroutines are cheap: ~2KB stack vs ~1MB for OS threads
  • Can create millions of them
  • Channels: "Don't communicate by sharing memory; share memory by communicating"
  • Type-safe communication between goroutines
  • Prevents many race conditions
  • select statement for multiplexing channels

Go's scheduler:

  • Go runtime multiplexes goroutines onto OS threads (M:N scheduling)
  • Automatically uses all CPU cores
  • Built into the language (not a library)

Comparison

FeatureJavaJavaScriptPythonGo
True parallelismYes (threads)No (single-threaded)No (GIL limits)Yes (goroutines)
LightweightVirtual threads (Java 21+)Very (event loop)No (threads heavy)Yes (goroutines)
CPU-bound tasksGoodPoorUse multiprocessingExcellent
I/O-bound tasksGoodExcellentasyncio goodExcellent
Learning curveMediumEasy (async/await)Medium (GIL confusion)Medium (channels)
Max concurrent units~Thousands (OS threads), millions (virtual threads)Unlimited (event loop)Limited (GIL)Millions (goroutines)

When to use what

Java:

  • Use virtual threads (Java 21+) for simple concurrent code
  • Use CompletableFuture for async operations
  • Use thread pools for controlled concurrency
  • Good for both CPU and I/O bound tasks

JavaScript:

  • Perfect for I/O-bound tasks (web servers, APIs)
  • Use async/await for everything
  • Don't use for CPU-heavy tasks (blocks the event loop)
  • Worker threads exist but rarely used

Python:

  • Use asyncio for I/O-bound tasks (network, disk)
  • Use multiprocessing for CPU-bound tasks (bypasses GIL)
  • Threading only useful for I/O-bound tasks
  • Avoid threading for CPU-bound work

Go:

  • Use goroutines for everything (cheap and simple)
  • Use channels for communication between goroutines
  • Excellent for both CPU and I/O bound tasks
  • Built-in concurrency is Go's main strength

Error Handling

Error handling is where Go takes a radically different approach from the other three languages. Java, JavaScript, and Python all use exceptions - errors that "bubble up" the call stack until caught. Go uses explicit error return values instead.

The fundamental difference

LanguageApproachPhilosophy
JavaExceptions (checked & unchecked)Exceptions for exceptional cases, forces handling
JavaScriptExceptions (unchecked only)Exceptions everywhere, but easy to ignore
PythonExceptions (unchecked only)"Easier to ask forgiveness than permission" (EAFP)
GoError return valuesErrors are values, handle explicitly at each step

Why Go chose this approach

Java, JavaScript, and Python: Use exceptions that can skip multiple layers of your code until someone catches them. This is convenient but can make control flow hard to follow - an error could come from anywhere deep in the call stack.

Go: Treats errors as normal return values. Every function that can fail returns an error as its last return value. You check it immediately, right where the error occurs.

// Go - explicit error checking at every step
file, err := os.Open("data.txt")
if err != nil {
    return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

data, err := io.ReadAll(file)
if err != nil {
    return fmt.Errorf("failed to read file: %w", err)
}

This is verbose, but it's very clear what can fail and where you're handling it.

The exceptions approach (Java, JS, Python)

// Java - try/catch blocks
try {
    String content = Files.readString(Path.of("data.txt"));
    processData(content);
} catch (IOException e) {
    System.err.println("File error: " + e.getMessage());
} catch (ProcessException e) {
    System.err.println("Processing error: " + e.getMessage());
}
// JavaScript - try/catch with async/await
try {
    const content = await fs.readFile('data.txt', 'utf8');
    await processData(content);
} catch (error) {
    console.error('Error:', error.message);
}
# Python - try/except blocks
try:
    with open('data.txt', 'r') as file:
        content = file.read()
    process_data(content)
except FileNotFoundError:
    print("File not found")
except ProcessError as e:
    print(f"Processing error: {e}")

Creating and handling errors

Java:

// Checked exception - must be declared or caught
public void readFile(String path) throws IOException {
    throw new IOException("File not found");
}

// Unchecked exception - no declaration needed
public void validateInput(String input) {
    if (input == null) {
        throw new IllegalArgumentException("Input cannot be null");
    }
}

// Custom exception
public class ValidationException extends Exception {
    public ValidationException(String message) {
        super(message);
    }
}

JavaScript:

// Throwing errors
function validateAge(age) {
    if (age < 0) {
        throw new Error('Age cannot be negative');
    }
}

// Custom error class
class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = 'ValidationError';
    }
}

// Promise rejection
async function fetchData() {
    throw new Error('Network error');
}

fetchData().catch(error => {
    console.error('Caught:', error);
});

Python:

# Raising exceptions
def validate_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")

# Custom exception
class ValidationError(Exception):
    pass

# Multiple exception handling
try:
    result = risky_operation()
except ValueError as e:
    print(f"Value error: {e}")
except KeyError as e:
    print(f"Key error: {e}")
except Exception as e:  # Catch-all
    print(f"Unexpected error: {e}")
finally:
    cleanup()  # Always runs

Go:

// Creating errors
import (
    "errors"
    "fmt"
)

// Simple error
func validateAge(age int) error {
    if age < 0 {
        return errors.New("age cannot be negative")
    }
    return nil
}

// Formatted error
func readConfig(path string) error {
    return fmt.Errorf("failed to read config from %s", path)
}

// Custom error type
type ValidationError struct {
    Field string
    Value interface{}
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed for %s: %v", e.Field, e.Value)
}

// Wrapping errors (Go 1.13+)
func processFile(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("processing failed: %w", err)
    }
    return nil
}

// Checking wrapped errors
err := processFile("data.txt")
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("File doesn't exist")
}

The Go verbosity problem

Here's what frustrates many developers coming to Go:

// This pattern repeats EVERYWHERE in Go
result1, err := step1()
if err != nil {
    return err
}

result2, err := step2(result1)
if err != nil {
    return err
}

result3, err := step3(result2)
if err != nil {
    return err
}

return result3, nil

Compare to Java:

// Clean and linear - exceptions bubble up automatically
try {
    var result1 = step1();
    var result2 = step2(result1);
    var result3 = step3(result2);
    return result3;
} catch (Exception e) {
    // Handle all errors in one place
    logger.error("Operation failed", e);
    throw e;
}

The Go argument for this: You always know exactly where errors can occur and how they're handled. There's no hidden control flow.

The counter-argument: It's extremely repetitive and makes code harder to read. The "happy path" gets lost in error checking.

Error handling patterns

PatternJavaJavaScriptPythonGo
Ignore errorsNot possible for checked exceptionsEasy (don't catch)Easy (don't catch)Easy (err is just a value)
Handle at call sitetry/catch around calltry/catch around calltry/except around callif err != nil
Bubble upRe-throw or declare throwsDon't catch (automatic)Don't catch (automatic)return err (explicit)
Wrap with contextthrow new Exception("msg", cause)throw new Error(msg)raise NewError(msg) from efmt.Errorf("msg: %w", err)
Multiple error typesMultiple catch blocksSingle catch, check typeMultiple except blocksType assertion or errors.As()

Panic and recover in Go (the exception escape hatch)

Go does have a panic/recover mechanism, but it's meant for truly exceptional situations (like out-of-bounds array access), not normal error handling:

// Panic - like throwing an exception (but don't do this!)
func riskyOperation() {
    panic("something went terribly wrong")
}

// Recover - like catching an exception
func safeOperation() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:", r)
        }
    }()

    riskyOperation() // This will panic
    fmt.Println("This won't execute")
}

Go community consensus: Don't use panic/recover for normal error handling. Use error return values.

Defer: Go's cleanup mechanism

Go has a unique feature called defer that schedules a function call to run when the surrounding function returns. It's primarily used for cleanup (closing files, releasing locks, etc.) and is executed even if the function panics.

Is defer related to finally? Yes! Both solve the same problem: ensuring cleanup code runs no matter how a function exits. The key difference is that defer lets you write cleanup code RIGHT AFTER acquiring a resource, while finally requires cleanup code at the END of a try/catch block, separated from where you acquired the resource.

Go:

func readFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }
    defer file.Close()  // Runs when function returns, no matter what

    // Do work with file...
    // Even if this code returns early or panics, file.Close() will run
    data, err := io.ReadAll(file)
    if err != nil {
        return err  // defer will still execute before this return
    }

    return nil  // defer executes here too
}

The defer statement is evaluated immediately (the arguments are evaluated), but the function call is delayed until the surrounding function returns.

Multiple defers execute in LIFO order (last in, first out):

func example() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    fmt.Println("function body")
}
// Output:
// function body
// 3
// 2
// 1

This no cmakes sense for cleanup: you want to clean up in reverse order of acquisition (close the last thing opened first).

How other languages handle cleanup

Java - try-with-resources:

// Automatic resource management (Java 7+)
try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
    String line = reader.readLine();
    // reader.close() called automatically at end of try block
} catch (IOException e) {
    System.err.println("Error: " + e.getMessage());
}

// Older approach - finally block
BufferedReader reader = null;
try {
    reader = new BufferedReader(new FileReader("file.txt"));
    String line = reader.readLine();
} catch (IOException e) {
    System.err.println("Error: " + e.getMessage());
} finally {
    // Always executes, even if exception thrown
    if (reader != null) {
        try {
            reader.close();
        } catch (IOException e) {
            // Handle close error
        }
    }
}

JavaScript - finally and cleanup:

// finally block
try {
    const result = riskyOperation();
} catch (error) {
    console.error('Error:', error);
} finally {
    // Always runs, like defer
    cleanup();
}

// No automatic resource management in JavaScript
// You just have to remember to clean up
async function readFile(path) {
    const file = await openFile(path);
    try {
        const data = await file.read();
        return data;
    } finally {
        await file.close();  // Runs even if error thrown
    }
}

Python - context managers (with statement):

# The 'with' statement handles cleanup automatically
with open('file.txt', 'r') as file:
    data = file.read()
    # file.close() called automatically when exiting the block
    # Even if exception is raised

# You can also use finally
file = None
try:
    file = open('file.txt', 'r')
    data = file.read()
finally:
    if file:
        file.close()  # Always executes

# Custom context manager
class DatabaseConnection:
    def __enter__(self):
        self.conn = create_connection()
        return self.conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Called automatically when exiting 'with' block
        self.conn.close()

with DatabaseConnection() as conn:
    conn.execute("SELECT * FROM users")

Comparison of cleanup mechanisms

FeatureJavaJavaScriptPythonGo
Similar to defer?finally block (less convenient)finally block (less convenient)finally block (less convenient)defer itself
Automatic cleanuptry-with-resourcesNo built-inwith statementdefer
Cleanup on errorfinally (always)finally (always)finally (always)defer (always)
When cleanup runsEnd of try blockEnd of try/catchEnd of with blockEnd of function
Cleanup locationAt end of try/catchAt end of try/catchAt end of withRight after acquisition
Multiple cleanupsNested try-with-resourcesNested try/finallyMultiple with or finallyMultiple defer (LIFO)
Requires interfaceYes (AutoCloseable)NoYes (__enter__/__exit__)No

Do JavaScript and Python have defer? No, but they have finally blocks that serve a similar purpose. The key difference: finally runs at the end of a try/catch block, while defer runs at the end of the entire function. Python's with statement is closer to defer in spirit (automatic cleanup), but it's block-scoped, not function-scoped.

Why defer is nice

1. Keep cleanup code next to acquisition:

// Opening and closing are visually close
file, err := os.Open("data.txt")
if err != nil {
    return err
}
defer file.Close()  // Right here, immediately after opening

// Now focus on the actual work
// You don't have to scroll down to find the cleanup code

Compare to Java finally:

File file = null;
try {
    file = new File("data.txt");
    // ... lots of code here ...
    // ... you might forget you need to close ...
} finally {
    // Cleanup code far away from acquisition
    if (file != null) {
        file.close();
    }
}

2. Multiple defers are easy:

func complexOperation() error {
    conn, err := database.Connect()
    if err != nil {
        return err
    }
    defer conn.Close()

    tx, err := conn.BeginTransaction()
    if err != nil {
        return err
    }
    defer tx.Rollback()  // Safe: rollback does nothing if already committed

    lock := acquireLock()
    defer lock.Release()

    // Do complex work...

    tx.Commit()
    return nil
}
// All defers execute in reverse order: Release(), Rollback(), Close()

In Java, this would require nested try-with-resources or a complex finally block.

3. Defer works with any function call:

mu.Lock()
defer mu.Unlock()  // Unlock a mutex

startTime := time.Now()
defer func() {
    // Log how long the function took
    fmt.Printf("Function took %v\n", time.Since(startTime))
}()

defer logFunctionExit()  // Log when function exits

// Anything can be deferred, not just AutoCloseable resources

Common defer gotchas in Go

1. Defer in a loop (potential leak):

// BAD: defers accumulate until function returns
func processFiles(paths []string) error {
    for _, path := range paths {
        file, err := os.Open(path)
        if err != nil {
            return err
        }
        defer file.Close()  // Won't close until function ends!

        // Process file...
    }
    // All files are still open here!
    return nil
}

// GOOD: Use a separate function
func processFiles(paths []string) error {
    for _, path := range paths {
        if err := processFile(path); err != nil {
            return err
        }
    }
    return nil
}

func processFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }
    defer file.Close()  // Closes when THIS function returns

    // Process file...
    return nil
}

2. Defer evaluates arguments immediately:

func example() {
    x := 1
    defer fmt.Println(x)  // Will print 1, not 3

    x = 2
    x = 3
}

// To capture the final value, use a closure:
func example() {
    x := 1
    defer func() {
        fmt.Println(x)  // Will print 3
    }()

    x = 2
    x = 3
}

When to use what

Use defer (Go) when:

  • You need to guarantee cleanup runs (even on panic)
  • You want cleanup code next to acquisition
  • You have multiple resources to clean up

Use try-with-resources (Java) when:

  • Working with AutoCloseable resources
  • You want automatic cleanup at end of try block
  • You need the compiler to enforce cleanup

Use context managers (Python) when:

  • You want automatic setup and teardown
  • Working with files, locks, database connections
  • You want to create reusable resource patterns

Use finally (all languages) when:

  • You need cleanup code that runs whether or not exception occurs
  • You have complex error handling needs
  • You're working in JavaScript (no other good option)

Multiple return values make it bearable

One thing that makes Go's approach less painful: functions can return multiple values easily:

// Return both result and error
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

result, err := divide(10, 0)
if err != nil {
    fmt.Println("Error:", err)
} else {
    fmt.Println("Result:", result)
}

In other languages, you'd either need to throw an exception or return a special wrapper object.

When to use what approach

Exceptions (Java, JS, Python) are better when:

  • You want clean "happy path" code
  • Errors should bubble up multiple layers
  • You're okay with hidden control flow
  • You want detailed stack traces

Explicit errors (Go) are better when:

  • You want to see exactly where errors can occur
  • You need very clear control flow
  • You want to force callers to think about errors
  • Performance matters (no stack unwinding overhead)

My opinion on Go's approach

Coming from Java, Go's error handling feels like death by a thousand paper cuts. The if err != nil pattern repeats so often that it drowns out the actual logic. Code that would be 10 lines in Java becomes 30 lines in Go.

However, I'll admit: when debugging, it's very clear where errors come from and how they're handled. There's no mystery about what exceptions might be thrown or caught. In large codebases, this explicit approach can actually make code easier to understand.

Is it worth the verbosity? That depends on your priorities. Go chose simplicity and explicitness over convenience - for better or worse.


Closing Thoughts

I'm still learning Go, so there's a good chance I've made mistakes or missed nuances in my explanations. If you spot something wrong, I'd appreciate the feedback.

This article is a work in progress. As I continue working with these languages, I'll keep enhancing and editing based on my evolving understanding. The goal isn't to master one language - it's to be able to code effectively in all of them, switching contexts as needed.

That's the real challenge: not just learning syntax, but understanding the why behind each language's design decisions. Hopefully this cheatsheet helps you (and future me) make those mental shifts a little easier.