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
| What | Java | JavaScript | Python | Go |
| Basic variable | String name = "John"; | let name = "John"; | name = "John" | name := "John" |
| With explicit type | String 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 constant | static final String NAME = "John"; | const NAME = "John"; | NAME = "John" | const NAME = "John" |
Why they're different
Java - final vs constants
finalmeans you can only assign once- Inside a method:
final String name = "John";- you can't reassignname - For a true constant (like
MAX_SIZE): usestatic finalin 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)constcreates 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
| What | Java | JavaScript | Python | Go |
| Create string | String name = "Hello"; | let name = "Hello"; | name = "Hello" | name := "Hello" |
| Concatenation | "Hello" + " " + "World" | `Hello ${name}` or "Hello" + name | f"Hello {name}" or "Hello" + name | "Hello " + name |
| Multiline | Text blocks """...""" (Java 15+) | Template literals `...` | Triple quotes """...""" | Backticks `...` |
| Immutable? | Yes | Yes | Yes | Yes |
| Character at index | s.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
| What | Java | JavaScript | Python | Go |
| Basic if | if (age > 18) { } | if (age > 18) { } | if age > 18: | if age > 18 { } |
| If-else | if (x) { } else { } | if (x) { } else { } | if x:\n ...\nelse:\n ... | if x { } else { } |
| Ternary | x > 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
| What | Java | JavaScript | Python | Go |
| Traditional for | for (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-each | for (String s : list) | for (const s of list) | for s in list: | for _, s := range list |
| While-style | while (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)
| What | Java | JavaScript | Python | Go |
| Create empty list | List<String> names = new ArrayList<>(); | const names = []; | names = [] | names := []string{} |
| Create with values | List<String> names = Arrays.asList("Alice", "Bob"); | const names = ["Alice", "Bob"]; | names = ["Alice", "Bob"] | names := []string{"Alice", "Bob"} |
| Add item | names.add("Charlie"); | names.push("Charlie"); | names.append("Charlie") | names = append(names, "Charlie") |
| Get item | names.get(0) | names[0] | names[0] | names[0] |
| Length | names.size() | names.length | len(names) | len(names) |
Maps/Dictionaries (key-value pairs)
| What | Java | JavaScript | Python | Go |
| Create empty map | Map<String, Integer> ages = new HashMap<>(); | const ages = {}; or new Map() | ages = {} | ages := make(map[string]int) |
| Create with values | See below | const ages = {Alice: 30, Bob: 25}; | ages = {"Alice": 30, "Bob": 25} | ages := map[string]int{"Alice": 30, "Bob": 25} |
| Add/update | ages.put("Alice", 30); | ages.Alice = 30; or ages["Alice"] = 30; | ages["Alice"] = 30 | ages["Alice"] = 30 |
| Get value | ages.get("Alice") | ages.Alice or ages["Alice"] | ages["Alice"] | ages["Alice"] |
| Check if key exists | ages.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:
| Feature | make() | new() |
| What it returns | Initialized value | Pointer to zeroed memory |
| Use for | Slices, maps, channels only | Any type |
| Returns ready to use? | Yes | No (need to initialize) |
| Common usage | Very common | Rare (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 valuenew(): Almost never use - just use&T{}instead- Java comparison:
make()is likenew ArrayList<>()- allocates AND initializes new()is like Java'snew- 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
ArrayListfor dynamic arrays - Use arrays
[]for fixed-size ArrayListhides 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
| What | Java | JavaScript | Python | Go |
| Simple data holder | Record (Java 14+) | Object literal | Tuple or NamedTuple | Struct |
| Syntax | record 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(), notgetX()) 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
| What | Java | JavaScript | Python | Go |
| Basic method | void greet(String name) { } | function greet(name) { } | def greet(name): | func greet(name string) { } |
| With return type | String getName() { return "John"; } | function getName() { return "John"; } | def get_name() -> str: return "John" | func getName() string { return "John" } |
| Multiple return values | Not supported | Not supported | return 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:
| Feature | Java/JavaScript/Python | Go |
| Pointers | Hidden (automatic references) | Explicit (* and &) |
| When you see it | Never - objects "just work" | Always - *Person or &person |
| Control | No choice - objects are always references | You choose: value or pointer |
| Performance | Everything is a reference (pointer overhead) | Small structs by value (no pointer overhead), large structs by pointer |
| Safety | Easy to accidentally mutate | Must 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 struct | Method 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:
camelCasefor methods/fields,PascalCasefor classes
JavaScript:
#for private (modern)- Convention:
_privateField(older code) - Naming:
camelCasefor everything
Python:
public_method()- no prefix_internal_method()- single underscore (don't use outside)__private_method()- double underscore (name mangling)- Naming:
snake_casefor everything
Go:
ExportedFunction()- uppercasenotExported()- lowercase- Naming:
MixedCaps(no underscores!)
Packages and Imports
How imports work
| What | Java | JavaScript | Python | Go |
| Import statement | import com.example.MyClass; | import { myFunc } from './module'; | import mymodule | import "github.com/user/package" |
| Import everything | import com.example.*; | import * as lib from './module'; | from mymodule import * | Automatic (all exported symbols) |
| Alias | N/A (use full name) | import { old as new } | import module as m | import m "package" |
| Package declaration | package com.example; | export keyword | N/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__.pymakes a directory a package (can be empty)- Module name = file name (without
.py) - Python looks in
sys.pathfor 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
packagedeclaration) - 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:
- 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
}
- 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 pointsinternal/- Private code (Go enforces this!)pkg/- Public libraries- Flat structure preferred (avoid deep nesting)
Access Control and Visibility
How visibility works
| Level | Java | JavaScript | Python | Go |
| Public (accessible everywhere) | public keyword | All exports | Everything by default | Uppercase name |
| Private (same class/package only) | private keyword | # prefix (ES2022) | _ prefix (convention) | Lowercase name |
| Protected (subclasses) | protected keyword | N/A | N/A | N/A |
| Package-private | No modifier (default) | N/A | N/A | Lowercase (same package) |
| Module-private | N/A | Not exported | _ prefix | N/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:
- Exported (uppercase) - accessible everywhere
- 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
| Feature | Java | JavaScript | Python | Go |
| Public syntax | public | Default or export | Default | Uppercase |
| Private syntax | private | #field | _field (convention) | lowercase |
| Enforcement | Compile-time | Compile-time (for #) | None (convention only) | Compile-time |
| Package-private | Default (no modifier) | No concept | No concept | lowercase |
| Protected | protected | No | No | No |
| Levels | 4 (public, protected, package, private) | 2 (public, private) | 1 (all public, convention) | 2 (exported, not exported) |
Concurrency: Async, Threads, and Channels
How concurrency works
| What | Java | JavaScript | Python | Go |
| Concurrency model | Threads (OS threads) | Event loop (single-threaded) | Threading + GIL, asyncio | Goroutines (lightweight threads) |
| Basic unit | Thread | Promise/async function | threading.Thread | goroutine |
| Async syntax | CompletableFuture | async/await | async/await | Native (just go) |
| Communication | Shared memory + locks | Callbacks/Promises | Queues, shared memory | Channels |
| 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
multiprocessingfor CPU-bound tasks (separate processes, no GIL) - Use
asynciofor 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
selectstatement 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
| Feature | Java | JavaScript | Python | Go |
| True parallelism | Yes (threads) | No (single-threaded) | No (GIL limits) | Yes (goroutines) |
| Lightweight | Virtual threads (Java 21+) | Very (event loop) | No (threads heavy) | Yes (goroutines) |
| CPU-bound tasks | Good | Poor | Use multiprocessing | Excellent |
| I/O-bound tasks | Good | Excellent | asyncio good | Excellent |
| Learning curve | Medium | Easy (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
CompletableFuturefor 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/awaitfor everything - Don't use for CPU-heavy tasks (blocks the event loop)
- Worker threads exist but rarely used
Python:
- Use
asynciofor I/O-bound tasks (network, disk) - Use
multiprocessingfor 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
| Language | Approach | Philosophy |
| Java | Exceptions (checked & unchecked) | Exceptions for exceptional cases, forces handling |
| JavaScript | Exceptions (unchecked only) | Exceptions everywhere, but easy to ignore |
| Python | Exceptions (unchecked only) | "Easier to ask forgiveness than permission" (EAFP) |
| Go | Error return values | Errors 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
| Pattern | Java | JavaScript | Python | Go |
| Ignore errors | Not possible for checked exceptions | Easy (don't catch) | Easy (don't catch) | Easy (err is just a value) |
| Handle at call site | try/catch around call | try/catch around call | try/except around call | if err != nil |
| Bubble up | Re-throw or declare throws | Don't catch (automatic) | Don't catch (automatic) | return err (explicit) |
| Wrap with context | throw new Exception("msg", cause) | throw new Error(msg) | raise NewError(msg) from e | fmt.Errorf("msg: %w", err) |
| Multiple error types | Multiple catch blocks | Single catch, check type | Multiple except blocks | Type 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
| Feature | Java | JavaScript | Python | Go |
| Similar to defer? | finally block (less convenient) | finally block (less convenient) | finally block (less convenient) | defer itself |
| Automatic cleanup | try-with-resources | No built-in | with statement | defer |
| Cleanup on error | finally (always) | finally (always) | finally (always) | defer (always) |
| When cleanup runs | End of try block | End of try/catch | End of with block | End of function |
| Cleanup location | At end of try/catch | At end of try/catch | At end of with | Right after acquisition |
| Multiple cleanups | Nested try-with-resources | Nested try/finally | Multiple with or finally | Multiple defer (LIFO) |
| Requires interface | Yes (AutoCloseable) | No | Yes (__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.

