PONY λ M2 Modula-2

R.CodeCompared.To/Go

An interactive executable cheatsheet comparing R and Go

R 4.6 Go 1.26.2
Basics
Hello, World
cat("Hello, World!\n")
package main import "fmt" func main() { fmt.Println("Hello, World!") }
Every Go program needs a package main declaration and a func main() entry point — there is no bare top-level statement the way R’s script files allow. fmt.Println adds the newline cat makes you write.
<- becomes := (and types get involved)
sample_size <- 30 mean_height <- 170.5 cat(sample_size, mean_height, "\n")
package main import "fmt" func main() { sampleSize := 30 // := declares AND assigns; type is inferred var meanHeight float64 // var declares with an explicit type meanHeight = 170.5 fmt.Println(sampleSize, meanHeight) }
:= is the everyday assignment — it declares a new variable and infers its type in one step, the closest cognitive match to R’s <-. var name Type is the explicit form, used when a variable needs a specific type or no initial value. Note camelCase, not snake_case.
Compiled, Not Interactive
The console habit stops working
# R runs each line as you write it — no separate "compile" step values <- c(1, 2, 3) print(sum(values)) # a typo shows up only when that line executes, and only THEN: tryCatch(print(sumx(values)), error = \(condition) { cat("runtime error:", conditionMessage(condition), "\n") })
package main import "fmt" func main() { values := []int{1, 2, 3} total := 0 for _, value := range values { total += value } fmt.Println(total) // a typo like sumx(values) is a COMPILE error — // the program never starts running at all }
There is no line-by-line REPL exploration as the primary workflow: the whole file is checked and compiled before anything runs, so a typo anywhere is caught before the first fmt.Println fires — not partway through a long analysis (the R column wraps the typo in tryCatch only so this comparison keeps running; unguarded, that line simply halts the script). Go does have a REPL-like go run for scripts and a real interactive shell exists via third-party tools, but the compile-first mental model is the one to adopt.
if is a statement, not an expression
sample_size <- 12 label <- if (sample_size >= 30) "large" else "small" print(label)
package main import "fmt" func main() { sampleSize := 12 var label string if sampleSize >= 30 { label = "large" } else { label = "small" } fmt.Println(label) }
Unlike R (and Scala, Julia, and Ruby), Go’s if has no value — it cannot be assigned. The R idiom of “assign the result of a conditional” becomes declare-then-branch-then-set. Conditions also need no parentheses, but the braces are mandatory, even for a single statement.
Everything Is Statically Typed
🚨 A variable has one type, forever
measurement <- 5 print(class(measurement)) measurement <- "five" # R happily lets a variable change type print(class(measurement))
package main import "fmt" func main() { measurement := 5 fmt.Printf("%T\n", measurement) // measurement = "five" is a COMPILE ERROR: // cannot use "five" (untyped string constant) as int value }
R lets any name hold any type at any time; Go fixes a variable’s type the moment it is declared (inferred from the first value with :=), and no later assignment may change it. This is the single biggest mental shift on the page — every value in the program has one, fixed, known type, checked before the program ever runs.
Implicit coercion is gone
print(1 + TRUE) # TRUE coerces to 1 — silently print(paste("count:", 5)) # 5 coerces to "5" — silently print(as.character(5)) print(as.integer("5"))
package main import ( "fmt" "strconv" ) func main() { // 1 + true does not compile — no automatic bool-to-int coercion fmt.Println("count: " + strconv.Itoa(5)) // explicit int-to-string parsed, _ := strconv.Atoi("5") // explicit string-to-int fmt.Println(parsed) }
R quietly converts between types whenever an operation needs it — booleans become numbers, numbers become strings inside paste. Go converts nothing automatically; every conversion is a named function call (strconv.Itoa, strconv.Atoi), and mixing types without one is a compile error. The upside: the conversion is always visible in the code, never a silent surprise buried in output.
No Vectorization At All
There is no library that vectorizes this
doses <- c(1, 2, 3) print(doses * 2) # elementwise, invisibly print(sqrt(doses)) print(sum(doses))
package main import ( "fmt" "math" ) func main() { doses := []float64{1, 2, 3} doubled := make([]float64, len(doses)) roots := make([]float64, len(doses)) total := 0.0 for index, dose := range doses { doubled[index] = dose * 2 roots[index] = math.Sqrt(dose) total += dose } fmt.Println(doubled) fmt.Println(roots) fmt.Println(total) }
This is the sharpest culture shock for an R user: Python has numpy, Julia has the dot, Scala has map — Go has none of these. A for/range loop is the ONLY way to touch every element of a slice; there is no elementwise-arithmetic library, standard or third-party, that Go idiom reaches for. The consolation: loops compile to genuinely fast native code, so the loop itself is never the bottleneck R trained you to fear.
Vectors → Slices
c() → a slice literal
heights <- c(160, 172, 181) print(heights[1]) # the first element print(heights[length(heights)]) # the last print(length(heights))
package main import "fmt" func main() { heights := []int{160, 172, 181} fmt.Println(heights[0]) // zero-based fmt.Println(heights[len(heights)-1]) // the last fmt.Println(len(heights)) }
A slice literal, []int{...}, replaces c(...) — the type of every element must be the same, and stated once, up front. Indexing is zero-based, and there is no end-style keyword: the last element is always slice[len(slice)-1].
Growing a slice: append, not c()
readings <- c(1, 2, 3) readings <- c(readings, 4) # rebuild the whole vector print(readings) subset <- readings[2:3] # inclusive, 1-based print(subset)
package main import "fmt" func main() { readings := []int{1, 2, 3} readings = append(readings, 4) // grows (and may reallocate) fmt.Println(readings) subset := readings[1:3] // half-open, 0-based: elements 2,3 fmt.Println(subset) }
append plays c(readings, 4) — note it returns a (possibly new) slice that must be reassigned, since the underlying array may need to grow. Slicing keeps R’s bracket syntax but flips to half-open, zero-based bounds: R’s 2:3 (inclusive, 1-based) becomes [1:3] (exclusive end, 0-based start).
Named Lists → Maps
Named lists → map[K]V
readings <- list(monday = 3.1, tuesday = 2.7) print(readings$monday) readings$wednesday <- 4.0 print(readings)
package main import "fmt" func main() { readings := map[string]float64{"monday": 3.1, "tuesday": 2.7} fmt.Println(readings["monday"]) readings["wednesday"] = 4.0 fmt.Println(readings) }
A named list becomes a map[KeyType]ValueType — but unlike R’s list, every key must be the same type and every value must be the same type, declared up front. Access uses brackets, not $, and a lookup by key or an assignment both use the same bracket syntax.
A missing key is not NULL — it is the zero value
readings <- list(monday = 3.1) print(readings$sunday) # NULL print(is.null(readings$sunday))
package main import "fmt" func main() { readings := map[string]float64{"monday": 3.1} fmt.Println(readings["sunday"]) // 0 — the zero value, not an error value, exists := readings["sunday"] fmt.Println(value, exists) // 0 false — the "comma ok" idiom }
A missing key returns the type’s zero value (0 for numbers, "" for strings) rather than R’s distinguishable NULL — a silent trap if a real zero and a missing key must be told apart. The fix is the “comma ok” idiom: value, exists := m[key] gives back a second boolean specifically for that question, the same shape errors use throughout the language.
Records → Structs
list(name =, age =) → struct
patient <- list(name = "Ada", age = 36) print(patient$name) patient$age <- 37 print(patient$age)
package main import "fmt" type Patient struct { Name string Age int } func main() { ada := Patient{Name: "Ada", Age: 36} fmt.Println(ada.Name) ada.Age = 37 fmt.Println(ada.Age) }
R’s tagged named list becomes a declared struct: fields typed once, up front, accessed with . instead of $. Unlike the list, no new field can be added later — the shape is fixed at the type declaration, and misspelling a field name is a compile error rather than a silent NULL.
S3 generics → methods with a receiver
patient <- structure(list(name = "Ada", age = 36), class = "patient") describe <- function(x) UseMethod("describe") describe.patient <- function(x) cat(x$name, "is", x$age, "\n") describe(patient)
package main import "fmt" type Patient struct { Name string Age int } func (patient Patient) Describe() { fmt.Println(patient.Name, "is", patient.Age) } func main() { ada := Patient{Name: "Ada", Age: 36} ada.Describe() }
S3’s generic-function-plus-class-tag dispatch becomes a method with an explicit receiver — the (patient Patient) before the function name — attaching Describe specifically to Patient. There is no inheritance and no UseMethod dispatch chain; each type simply owns the methods declared with its name as receiver.
Functions
Every parameter and return value is typed
standardize <- function(values) { (values - mean(values)) / sd(values) } print(standardize(c(10, 20, 30)))
package main import ( "fmt" "math" ) func standardize(values []float64) []float64 { total := 0.0 for _, value := range values { total += value } average := total / float64(len(values)) varianceSum := 0.0 for _, value := range values { varianceSum += math.Pow(value-average, 2) } deviation := math.Sqrt(varianceSum / float64(len(values)-1)) result := make([]float64, len(values)) for index, value := range values { result[index] = (value - average) / deviation } return result } func main() { fmt.Println(standardize([]float64{10, 20, 30})) }
Every parameter type and the return type are declared in the signature — calling standardize("oops") is a compile error, not a runtime surprise. return is mandatory (no last-expression-is-the-value the way R and several other languages here allow), and there is no built-in mean/sd — base Go has almost no statistics functions at all.
Multiple return values, no list() needed
min_max <- function(values) { list(minimum = min(values), maximum = max(values)) } result <- min_max(c(5, 2, 8, 1)) cat(result$minimum, result$maximum, "\n")
package main import "fmt" func minMax(values []int) (int, int) { minimum, maximum := values[0], values[0] for _, value := range values { if value < minimum { minimum = value } if value > maximum { maximum = value } } return minimum, maximum } func main() { minimum, maximum := minMax([]int{5, 2, 8, 1}) fmt.Println(minimum, maximum) }
Where R bundles multiple results into a list() and unpacks it with $, Go returns multiple values natively — (int, int) in the signature, unpacked directly into two names at the call site. This mechanism is also the backbone of the error-handling idiom in the next section.
NA & tryCatch → (value, error)
Errors are ordinary return values
safe_divide <- function(numerator, denominator) { tryCatch({ if (denominator == 0) stop("division by zero") numerator / denominator }, error = function(condition) { cat("caught:", conditionMessage(condition), "\n") NA }) } print(safe_divide(10, 2)) print(safe_divide(10, 0))
package main import ( "errors" "fmt" ) func safeDivide(numerator, denominator float64) (float64, error) { if denominator == 0 { return 0, errors.New("division by zero") } return numerator / denominator, nil } func main() { result, err := safeDivide(10, 2) if err != nil { fmt.Println("caught:", err) } else { fmt.Println(result) } result, err = safeDivide(10, 0) if err != nil { fmt.Println("caught:", err) } else { fmt.Println(result) } }
There is no exception mechanism in everyday Go: a function that can fail returns (value, error), and the caller checks if err != nil immediately after every call — tryCatch’s job is done by an ordinary conditional, repeated at every call site rather than wrapped once around a block. It reads as more ceremony per call and gives back one guarantee tryCatch cannot: every failure point is visible in the code, not hidden until a stop() fires three functions deep.
panic/recover — for the truly exceptional
check_positive <- function(value) { if (value < 0) stop("value must be non-negative") sqrt(value) } result <- tryCatch(check_positive(-4), error = function(condition) { cat("recovered:", conditionMessage(condition), "\n") NA }) print(result)
package main import "fmt" func checkPositive(value int) (result int, recovered bool) { defer func() { if caught := recover(); caught != nil { fmt.Println("recovered:", caught) recovered = true } }() if value < 0 { panic("value must be non-negative") } return value, false } func main() { result, wasRecovered := checkPositive(-4) fmt.Println(result, wasRecovered) }
panic/recover is the closer analog to stop/tryCatch — but idiomatic Go reserves it for programmer errors and truly unrecoverable states, never for ordinary failure the way R’s conditions handle both. Expected failures (division by zero, a missing file) get the (value, error) treatment above; panic is closer to “this should never happen.”
apply Family → Goroutines
Real concurrency, not a bolted-on package
# R's concurrency is a library, and it forks whole processes: # library(parallel) # results <- mclapply(1:4, function(worker) worker^2, mc.cores = 4) # print(results) for (worker in 1:4) { cat("worker", worker, "squared is", worker^2, "\n") }
package main import ( "fmt" "sync" ) func main() { var waitGroup sync.WaitGroup for worker := 1; worker <= 4; worker++ { waitGroup.Add(1) go func(id int) { defer waitGroup.Done() fmt.Println("worker", id, "squared is", id*id) }(worker) } waitGroup.Wait() // block until every goroutine finishes }
R’s concurrency story is a library (parallel) that forks entire OS processes — heavyweight, and platform-dependent (mclapply does not fork on Windows). The go keyword launches a goroutine — a lightweight, runtime-scheduled unit of concurrency, thousands of which run comfortably on one machine — built into the language itself. sync.WaitGroup is how the caller waits for a batch to finish, replacing mclapply’s implicit blocking-until-done.
Channels
Channels: how goroutines hand off results
# R has no equivalent — mclapply collects results for you, invisibly. # Go makes the hand-off explicit and visible in the code: squares <- sapply(1:4, \(worker) worker^2) print(squares)
package main import "fmt" func main() { results := make(chan int, 4) for worker := 1; worker <= 4; worker++ { go func(id int) { results <- id * id // send the result on the channel }(worker) } total := 0 for count := 0; count < 4; count++ { total += <-results // receive one result } fmt.Println(total) }
Where mclapply silently collects every worker’s result into a list for you, Go makes the hand-off an explicit, typed channel: goroutines send with <- pointed at the channel, the receiver receives with <- pointed away from it. A buffered channel (the 4 above) lets sends proceed without a receiver standing by yet, up to that capacity.
Strings
paste & sprintf → fmt verbs
name <- "Ada" trials <- 3 cat(paste("Subject", name, "completed", trials, "trials"), "\n") cat(sprintf("%s: %.1f%% done\n", name, 66.67))
package main import "fmt" func main() { name := "Ada" trials := 3 fmt.Println("Subject", name, "completed", trials, "trials") fmt.Printf("%s: %.1f%% done\n", name, 66.67) }
fmt.Println takes any number of arguments the way cat does, spacing them automatically — most paste calls translate directly. fmt.Printf is nearly the same format-string dialect as sprintf (%s, %.1f, %%), one of the more comfortable landings on this page.
String functions live in strings and strconv
phrase <- "stitch in time" print(toupper(phrase)) print(nchar(phrase)) print(strsplit(phrase, " ")[[1]]) print(grepl("time", phrase))
package main import ( "fmt" "strings" ) func main() { phrase := "stitch in time" fmt.Println(strings.ToUpper(phrase)) fmt.Println(len(phrase)) // byte length — see the note below fmt.Println(strings.Split(phrase, " ")) fmt.Println(strings.Contains(phrase, "time")) }
Most string operations live in the strings package as free functions (strings.ToUpper, not a method) rather than R’s standalone-function style, so the call shape is unusually close to home. One gotcha worth flagging: len(phrase) counts BYTES, not characters — correct for ASCII text like this example, but a trap the moment non-ASCII text (emoji, accented letters) enters, where []rune(phrase) is needed instead.
The Deployment Story
The deployment story
# Shipping an R analysis usually means shipping R itself: # - the R interpreter and its exact version # - every library() dependency, often pinned with renv # - possibly a Docker image, since "works on my machine" is real # install.packages(c("dplyr", "ggplot2"))
// Shipping a Go analysis means shipping ONE FILE: // go build produces a single static binary — no runtime, no // interpreter, no dependency tree to install on the target machine // // go build -o analyzer main.go // scp analyzer remote-server:/usr/local/bin/ // ./analyzer # just runs — nothing else needed on that machine
This is the practical payoff of the whole migration: go build produces one statically linked binary with everything baked in — copy it to any machine of the same OS/architecture and run it, no R installation, no renv lockfile, no Docker image required just to reproduce the environment. It is the single biggest operational reason data teams add a Go service beside an R analysis pipeline. Both cells are illustrative, shown display-only.