Basics
Hello, World
cat("Hello, World!\n") object Main {
def main(args: Array[String]): Unit = {
println("Hello, World!")
}
} The first difference is the ceremony: Scala is compiled, and a program needs an entry point — an
object Main with a main method — before it prints anything. Every example on this page carries this wrapper. println adds the newline cat makes you write.<- becomes val (and bindings stop moving)
sample_size <- 30
sample_size <- 31 # any binding can be freely reassigned
mean_height <- 170.5
cat(sample_size, mean_height, "\n") object Main {
def main(args: Array[String]): Unit = {
val sampleSize = 30 // val: cannot be reassigned — the default
var meanHeight = 170.0 // var: reassignable, used sparingly
meanHeight = 171.5
println(sampleSize + " " + meanHeight)
}
} Assignment splits in two:
val declares a name that can never be rebound (the overwhelming default in idiomatic Scala), var one that can. The types are there — sampleSize is an Int, meanHeight a Double — but inferred, so declarations read as lightly as R’s. Convention also switches from snake_case to camelCase.if is still an expression
sample_size <- 12
label <- if (sample_size >= 30) "large" else "small"
print(label)
# and ifelse() handles the vectorized case
print(ifelse(c(10, 40) >= 30, "large", "small")) object Main {
def main(args: Array[String]): Unit = {
val sampleSize = 12
val label = if (sampleSize >= 30) "large" else "small"
println(label)
}
} A genuine comfort: like R (and unlike Python or Java), Scala’s
if is an expression with a value, so the assign-a-conditional idiom transfers verbatim. There is no vectorized ifelse — that becomes values.map(value => if (...) ... else ...), the shape the collections section explains.Zero-Based, in Parentheses
🚨 x[1] becomes x(0)
heights <- c(160, 172, 181)
print(heights[1]) # the first element
print(heights[length(heights)]) # the last object Main {
def main(args: Array[String]): Unit = {
val heights = Vector(160, 172, 181)
println(heights(0)) // parentheses — and counting starts at zero
println(heights.head) // or ask by name
println(heights.last) // no length arithmetic needed
}
} Two changes stack: counting starts at zero, and indexing uses parentheses, not brackets —
heights(0) is really the method call heights.apply(0). In practice idiomatic Scala indexes rarely: head, last, and the collection methods in the next sections ask for elements by role instead of by position.x[-1] → tail (dropping by name)
heights <- c(160, 172, 181, 195)
print(heights[-1]) # drop the first
print(heights[-length(heights)]) # drop the last
print(heights[-c(1, 2)]) # drop the first two
print(tail(heights, 2)) # keep the last two object Main {
def main(args: Array[String]): Unit = {
val heights = Vector(160, 172, 181, 195)
println(heights.tail) // drop the first — R's heights[-1]
println(heights.init) // drop the last
println(heights.drop(2)) // drop the first two
println(heights.takeRight(2)) // keep the last two
}
} Negative indexing does not exist; every exclusion R spells with a minus sign has a named method —
tail, init, drop, dropRight, take, takeRight. The names read better than the arithmetic, and there is no silent behavior change waiting when a negative number arrives at an index by accident.Collections Without Vectorization
map is the universal spelling
doses <- c(1, 2, 3)
print(doses * 2) # arithmetic vectorizes invisibly
print(sqrt(doses))
print(mean(doses)) object Main {
def main(args: Array[String]): Unit = {
val doses = Vector(1.0, 2.0, 3.0)
println(doses.map(_ * 2)) // nothing vectorizes on its own
println(doses.map(math.sqrt))
println(doses.sum / doses.length) // mean, assembled by hand
}
} There is no elementwise arithmetic on collections —
doses * 2 does not compile. map is the one universal spelling for “apply this to each element,” with the underscore as shorthand for a one-argument anonymous function. Note also what base Scala lacks: no built-in mean or sd — summary statistics come from libraries (Breeze, Spark) or three characters of arithmetic.1:5 → 1 to 5 (still inclusive)
print(1:5) # inclusive
print(seq(0, 10, by = 2))
for (trial in 1:3) {
cat("trial", trial, "\n")
} object Main {
def main(args: Array[String]): Unit = {
println((1 to 5).toList) // inclusive, like R's 1:5
println((0 to 10 by 2).toList) // seq(by =) reads as English
println((1 until 5).toList) // half-open, when you want it
for (trial <- 1 to 3)
println("trial " + trial)
}
} Ranges keep R’s inclusive ends —
1 to 5 has five elements — and seq(0, 10, by = 2) becomes the almost-English 0 to 10 by 2. The until variant is half-open for the occasions zero-based indexing wants it. These read as method calls because they are: to, by, and until are ordinary methods on Int.sapply + subset → for/yield
doses <- c(10, 20, 30)
print(sapply(doses, \(dose) dose / 10))
print(doses[doses > 15])
print(sapply(doses[doses > 15], \(dose) dose / 10)) object Main {
def main(args: Array[String]): Unit = {
val doses = Vector(10, 20, 30)
val scaled = for (dose <- doses if dose > 15) yield dose / 10.0
println(scaled)
}
} The
for/yield comprehension covers sapply plus logical subsetting in one construct: iterate, filter with the inline if, and yield builds a new collection of the results. It is syntactic sugar for the filter/map chains of the next section — use whichever reads better case by case.dplyr Verbs → Collection Methods
The pipeline reads the same
survey <- data.frame(
name = c("Ada", "Grace", "Mary"),
age = c(36, 41, 58)
)
adults <- subset(survey, age > 40)
adults <- adults[order(adults$age, decreasing = TRUE), ]
print(adults$name) case class Person(name: String, age: Int)
object Main {
def main(args: Array[String]): Unit = {
val survey = Seq(Person("Ada", 36), Person("Grace", 41), Person("Mary", 58))
val names = survey
.filter(_.age > 40)
.sortBy(person => -person.age)
.map(_.name)
println(names)
}
} The dplyr vocabulary maps almost one-to-one onto collection methods:
filter is filter, arrange is sortBy, mutate/select are map, distinct is distinct — and the chain reads exactly like a magrittr pipeline with dots for pipes. The “data frame” here is a plain Seq of case-class rows; Spark’s DataFrame API (the ecosystem section) scales this same shape to a cluster.aggregate → groupBy
measurements <- data.frame(
group = c("a", "a", "b", "b"),
value = c(10, 20, 30, 40)
)
print(aggregate(value ~ group, data = measurements, FUN = mean)) case class Measurement(group: String, value: Double)
object Main {
def main(args: Array[String]): Unit = {
val measurements = Seq(
Measurement("a", 10.0), Measurement("a", 20.0),
Measurement("b", 30.0), Measurement("b", 40.0)
)
val averages = measurements
.groupBy(_.group)
.map { case (group, rows) =>
group -> rows.map(_.value).sum / rows.length
}
println(averages.toSeq.sortBy(_._1))
}
} Split-apply-combine is
groupBy followed by a map over the resulting (key, rows) pairs — more manual than a formula, but the same shape Spark’s groupBy(...).agg(...) industrializes. One habit to note: groupBy returns an unordered Map, so deterministic output means sorting at the end.Immutability by Default
Copy-on-modify, formalized
original <- c(1, 2, 3)
appended <- c(original, 4)
updated <- original
updated[1] <- 99
print(original) # always untouched — R copies on modify
print(appended)
print(updated) object Main {
def main(args: Array[String]): Unit = {
val original = Vector(1, 2, 3)
val appended = original :+ 4 // a NEW vector — original untouched
val updated = original.updated(0, 99) // likewise
println(original)
println(appended)
println(updated)
}
} The R instinct that “my data cannot change out from under me” — the one Python and Julia break — is a design principle here: the default collections are immutable, and every “modification” (
:+, updated, filter) returns a new collection. Structural sharing makes it cheap, unlike R’s full copies, and scala.collection.mutable is the explicit opt-out for hot loops.Functions
Functions grow type annotations
standardize <- function(values) {
(values - mean(values)) / sd(values) # last expression is returned
}
print(standardize(c(10, 20, 30))) object Main {
def standardize(values: Seq[Double]): Seq[Double] = {
val average = values.sum / values.length
val variance = values.map(value => math.pow(value - average, 2)).sum / (values.length - 1)
val deviation = math.sqrt(variance)
values.map(value => (value - average) / deviation) // still returned as the last expression
}
def main(args: Array[String]): Unit = {
println(standardize(Seq(10.0, 20.0, 30.0)))
}
} Parameter types are mandatory (
values: Seq[Double]) and the return type is good manners; in exchange, calling standardize("oops") is a compile error instead of a runtime surprise mid-analysis. One comfort survives intact: the last expression is the return value, no return keyword needed.\(x) → _ (the underscore shorthand)
doses <- c(1, 2, 3)
print(sapply(doses, \(dose) dose * 2))
print(Filter(\(dose) dose > 1, doses)) object Main {
def main(args: Array[String]): Unit = {
val doses = Vector(1, 2, 3)
println(doses.map(dose => dose * 2)) // full form
println(doses.map(_ * 2)) // underscore shorthand
println(doses.filter(_ > 1).map(_ + 10))
}
} The full anonymous-function form is
argument => expression; when the argument is used exactly once, the underscore stands in for it — _ * 2 is Scala’s tersest lambda. Each underscore in an expression is a different argument (_ + _ takes two), which surprises everyone once.Named arguments and defaults survive
rescale <- function(values, center = 0, scale = 1) {
(values - center) / scale
}
print(rescale(c(10, 20, 30), center = 20))
print(rescale(center = 20, c(10, 20, 30))) object Main {
def rescale(values: Seq[Double], center: Double = 0.0, scale: Double = 1.0): Seq[Double] =
values.map(value => (value - center) / scale)
def main(args: Array[String]): Unit = {
println(rescale(Seq(10.0, 20.0, 30.0), center = 20.0))
println(rescale(center = 20.0, values = Seq(10.0, 20.0, 30.0)))
}
} Two R conveniences most compiled languages drop both survive: default argument values in the signature, and calling any argument by name — in any order, once named. The one difference is that R’s partial name matching (
cent = 20) does not exist; names must be exact, and the compiler tells you when they are not.... → Double* (slurp and splat)
report <- function(label, ...) {
extras <- list(...)
cat(label, ":", length(extras), "extra values\n")
}
report("run", 1, 2, 3)
do.call(report, c(list("splat"), as.list(4:6))) object Main {
def report(label: String, values: Double*): Unit =
println(label + ": " + values.length + " extra values")
def main(args: Array[String]): Unit = {
report("run", 1, 2, 3)
val numbers = Seq(4.0, 5.0, 6.0)
report("splat", numbers: _*) // splat a collection into arguments
}
} R’s
... becomes a typed variadic parameter — values: Double* arrives as a ready-to-use sequence, no list(...) unwrapping — and the : _* ascription splats an existing collection into the argument list, retiring do.call. Being typed, the slurp only accepts what it declares: a stray string in the arguments is a compile error.NA → Option
Absence moves into the type
readings <- list(monday = 3.1, tuesday = 2.7)
print(readings$monday)
print(readings$sunday) # NULL — silently
value <- if (is.null(readings$sunday)) 0 else readings$sunday
print(value) object Main {
def main(args: Array[String]): Unit = {
val readings = Map("monday" -> 3.1, "tuesday" -> 2.7)
println(readings.get("monday")) // Some(3.1)
println(readings.get("sunday")) // None — absence as a VALUE
println(readings.get("monday").getOrElse(0.0))
println(readings.get("sunday").getOrElse(0.0))
}
} Scala has no
NA and (in idiomatic code) no NULL-alike: a lookup that can fail returns Option[Double] — either Some(value) or None — and the type forces the “what if it is missing?” decision at compile time, where R lets NULL slide silently until something downstream misbehaves. getOrElse is the one-line fallback.na.rm → flatten
values <- c(1, 2, NA, 4)
print(mean(values)) # NA propagates
print(mean(values, na.rm = TRUE))
values[is.na(values)] <- 0
print(values) object Main {
def main(args: Array[String]): Unit = {
val values = Seq(Some(1.0), Some(2.0), None, Some(4.0))
val present = values.flatten // drops the Nones — na.rm = TRUE
println(present)
println(present.sum / present.length)
println(values.map(_.getOrElse(0.0))) // or replace them
}
} A column with missing values is
Seq[Option[Double]] — the missingness is in the type, not hidden in the data — and arithmetic will not compile until it is dealt with: flatten drops the Nones (the na.rm = TRUE move), map(_.getOrElse(...)) replaces them. There is no propagating NA; the compiler makes you choose up front, once. (Spark’s columns handle nulls more R-like, with na.drop/na.fill.)Named Lists → Case Classes
list(name =, age =) → case class
patient <- list(name = "Ada", age = 36)
class(patient) <- "patient"
print(patient$name)
str(patient)
print(identical(patient, structure(list(name = "Ada", age = 36), class = "patient"))) case class Patient(name: String, age: Int)
object Main {
def main(args: Array[String]): Unit = {
val ada = Patient("Ada", 36)
println(ada.name)
println(ada) // readable printing for free
println(ada == Patient("Ada", 36)) // structural equality for free
}
} R’s tagged named list becomes a
case class: fields declared and typed once, accessed with . instead of $ — and misspelling a field is a compile error, where patient$aeg silently returns NULL. The case keyword bundles what R makes you improvise: readable printing, structural equality, and the destructuring the pattern-matching section uses.Non-destructive update via copy
patient <- list(name = "Ada", age = 36)
updated <- patient
updated$age <- 37 # copy-on-modify: the original never changes
print(patient$age)
print(updated$age) case class Patient(name: String, age: Int)
object Main {
def main(args: Array[String]): Unit = {
val ada = Patient("Ada", 36)
val older = ada.copy(age = 37) // a new value — ada is untouched
println(ada.age)
println(older.age)
}
} What R does implicitly through copy-on-modify, Scala does explicitly through
copy: name the fields that change, keep the rest, get a new value. Case-class instances are immutable, so the original genuinely cannot drift — the same safety R gives, delivered by construction rather than by copying.switch → match
switch → match
describe <- function(status) {
switch(status,
pending = "still running",
complete = "all done",
paste("unknown:", status) # unnamed = the default
)
}
cat(describe("pending"), "\n")
cat(describe("failed"), "\n") object Main {
def describe(status: String): String = status match {
case "pending" => "still running"
case "complete" => "all done"
case other => "unknown: " + other
}
def main(args: Array[String]): Unit = {
println(describe("pending"))
println(describe("failed"))
}
} match is an expression (like everything here), tries its cases top to bottom, and binds the fallback to a name instead of R’s anonymous trailing argument. This literal-matching form is the shallow end; the next row is why match is one of Scala’s defining features.Destructuring, checked for exhaustiveness
# S3 dispatch: each class needs a method, and nothing checks coverage
predict_response <- function(model, input) UseMethod("predict_response")
predict_response.linear <- function(model, input) {
model$slope * input + model$intercept
}
predict_response.constant <- function(model, input) model$value
linear <- structure(list(slope = 2, intercept = 1), class = "linear")
constant <- structure(list(value = 5), class = "constant")
print(predict_response(linear, 10))
print(predict_response(constant, 10)) sealed trait Model
case class Linear(slope: Double, intercept: Double) extends Model
case class Constant(value: Double) extends Model
object Main {
def predict(model: Model, input: Double): Double = model match {
case Linear(slope, intercept) => slope * input + intercept
case Constant(value) => value
}
def main(args: Array[String]): Unit = {
println(predict(Linear(2.0, 1.0), 10.0))
println(predict(Constant(5.0), 10.0))
}
} The S3 pattern — a family of classes, behavior chosen by class — becomes a
sealed trait with case-class variants, and match destructures each variant’s fields right in the pattern. sealed is the payoff: the compiler knows every possible Model and warns when a case is missing, where S3 offers only the hope that every class got a method.Strings
paste & sprintf → s"..." and f"..."
name <- "Ada"
trials <- 3
completion <- 66.67
cat(paste("Subject", name, "completed", trials, "trials"), "\n")
cat(sprintf("%s: %.1f%% done\n", name, completion)) object Main {
def main(args: Array[String]): Unit = {
val name = "Ada"
val trials = 3
val completion = 66.67
println(s"Subject $name completed $trials trials")
println(f"$name%s: $completion%.1f%% done")
}
} The
s interpolator splices values inline, retiring most paste calls; the f interpolator is sprintf with the format spec attached directly to each value — and, unlike sprintf, the compiler checks that %.1f is actually applied to a number. Joining a vector inverts subject and verb: paste(words, collapse = ", ") is words.mkString(", ").String functions → methods
phrase <- "stitch in time"
print(toupper(phrase))
print(nchar(phrase))
print(strsplit(phrase, " ")[[1]])
print(sub("time", "space", phrase))
print(grepl("time", phrase)) object Main {
def main(args: Array[String]): Unit = {
val phrase = "stitch in time"
println(phrase.toUpperCase)
println(phrase.length)
println(phrase.split(" ").toList)
println(phrase.replace("time", "space"))
println(phrase.contains("time"))
}
} Function-wrapping becomes method-calling —
toupper(x) is x.toUpperCase — and the strsplit(...)[[1]] dance disappears because split returns the array directly. replace here is literal; regular expressions use replaceAll or Scala’s Regex class with pattern matching.Error Handling
tryCatch → Try (a value, like yours)
result <- tryCatch({
stop("model failed to converge")
}, error = function(condition) {
cat("caught:", conditionMessage(condition), "\n")
NA
})
print(result) import scala.util.{Try, Success, Failure}
object Main {
def main(args: Array[String]): Unit = {
val parsed = Try("3.5".toDouble)
val broken = Try("not a number".toDouble)
println(parsed.getOrElse(Double.NaN))
println(broken.getOrElse(Double.NaN))
broken match {
case Success(value) => println("parsed: " + value)
case Failure(caught) => println("caught: " + caught.getMessage)
}
}
} R users already think of error handling as producing a value —
tryCatch returns one — and Try is exactly that idea as a type: Success(value) or Failure(exception), pattern-matchable, with getOrElse as the fallback shorthand. The imperative try/catch/finally also exists, but Try is the shape that composes with everything else on this page.Pipes → Method Chains
The dot is the pipe
measurements <- c(12, 7, 25, 31, 8)
measurements |>
(\(values) values[values > 10])() |>
sort() |>
head(3) |>
print() object Main {
def main(args: Array[String]): Unit = {
val measurements = Vector(12, 7, 25, 31, 8)
val result = measurements
.filter(_ > 10)
.sorted
.take(3)
println(result)
}
} No pipe operator is needed because every step is a method returning a new collection — the dot does what
|> does, without the unary-function contortions R’s pipe needs for filtering. This is the same left-to-right sentence dplyr taught, and it is the native shape of the language rather than an operator bolted on.The Ecosystem Map (Spark)
The ecosystem map
# The R road into big data...
library(sparklyr) # dplyr verbs over a Spark cluster
library(dplyr)
# spark_connect(), then filter/mutate/summarize as usual
# — every verb is TRANSLATED to Spark behind the scenes
# install.packages("sparklyr") // ...and the native side of the same engine:
import org.apache.spark.sql.SparkSession
// SparkSession.builder.getOrCreate(), then
// dataset.filter(...).groupBy(...).agg(...) — no translation layer
// Breeze — vectors, matrices, linear algebra (numpy/Matrix territory)
// Smile — statistics and machine learning on the JVM
// Almond — a Scala kernel for Jupyter notebooks
// Managed with sbt (build.sbt) or scala-cli, not install.packages The reason this page exists: Spark is written in Scala, and sparklyr is a translation layer over it — moving to Scala removes the translation, unlocks the typed
Dataset API (case-class rows, compile-checked columns), and makes the cluster’s full API surface available the day it ships. Around it: Breeze for linear algebra, Smile for statistics and machine learning, Almond to keep the notebook workflow. Both cells are import inventories, shown display-only.