Skip to content

06 · Classes & Objects Basics

Defining a class

class Person(val name: String, var age: Int):
  def greet(): String =
    s"Hi, I'm $name and I'm $age years old."

val ada = Person("Ada", 28)
println(ada.name)          // Ada
println(ada.greet())       // Hi, I'm Ada and I'm 28 years old.

ada.age = 29                // allowed -- age was declared with var
println(ada.age)            // 29
// ada.name = "Grace"       // error -- name was declared with val

The constructor parameters go directly in the class signature. Prefixing a parameter with val (or var) both stores it as a field and generates a public accessor — this is far more concise than Java's constructor + private field + getter boilerplate.

Methods and fields

class Rectangle(val width: Double, val height: Double):
  def area: Double = width * height          // no-parens method -- reads like a field
  def perimeter(): Double = 2 * (width + height)

  def scaled(factor: Double): Rectangle =
    Rectangle(width * factor, height * factor)

val r = Rectangle(3.0, 4.0)
println(r.area)             // 12.0 -- called without parentheses
println(r.perimeter())      // 14.0
println(r.scaled(2.0).area) // 48.0

Notice scaled returns a brand-new Rectangle rather than mutating r — this mirrors the immutable-by-default style from earlier modules and carries through the rest of the course.

Auxiliary constructors

class Point(val x: Double, val y: Double):
  def this() = this(0.0, 0.0)   // auxiliary constructor -- must call the primary one

  def describe: String = s"($x, $y)"

val origin = Point()
val custom = Point(3.0, 4.0)
println(origin.describe)   // (0.0, 0.0)
println(custom.describe)   // (3.0, 4.0)

object — singletons

An object declares a class with exactly one instance, created lazily on first use. It's Scala's replacement for static members in Java:

object MathUtils:
  val Pi = 3.14159
  def square(n: Double): Double = n * n

println(MathUtils.Pi)          // 3.14159
println(MathUtils.square(5))   // 25.0

Companion objects

An object with the same name as a class, declared in the same file, is that class's companion object. It's the idiomatic place for factory methods and constants related to the class:

class Circle private (val radius: Double):
  def area: Double = Circle.Pi * radius * radius

object Circle:
  val Pi = 3.14159

  // Factory method -- lets callers write Circle(radius) instead of new Circle(radius)
  def apply(radius: Double): Circle =
    if radius < 0 then throw new IllegalArgumentException("radius must be >= 0")
    else new Circle(radius)

val c = Circle(2.0)          // calls Circle.apply -- no "new" needed
println(c.area)              // 12.56636

Circle's primary constructor is marked private, so the only way to build one from outside the file is through the companion's apply — a common pattern for validating inputs at construction time.

apply — why Circle(2.0) works

Any object (or class) with an apply method can be "called" like a function — Scala rewrites Circle(2.0) to Circle.apply(2.0) automatically. This is the same mechanism that lets you write List(1, 2, 3) instead of List.apply(1, 2, 3).

Visibility modifiers

class BankAccount(private var balance: Double):
  def deposit(amount: Double): Unit =
    balance += amount

  def currentBalance: Double = balance   // controlled read access

val account = BankAccount(100.0)
account.deposit(50.0)
println(account.currentBalance)   // 150.0
// account.balance -- error: balance is private

private restricts a member to the class itself (and, in Scala, its companion object too). There's no field to accidentally expose — callers only ever see the methods you choose to publish.

How It Actually Works

Scala's class compiles to an ordinary JVM class — constructor parameters declared with val/var become private fields with generated getters (and setters for var), and the "primary constructor" body (any top-level statements in the class body) is compiled into the JVM's <init> method, same as any Java constructor. Auxiliary constructors (def this(...)) compile to overloaded <init> methods that must ultimately delegate to the primary constructor — the compiler enforces this by requiring the first statement of an auxiliary constructor to be a call to this(...).

object (a singleton) has no direct Java equivalent, so the compiler builds one out of ordinary pieces: it generates a class named Foo$ with a single public static final Foo$ MODULE$ field holding the one instance, initialized in a static initializer the first time the class is loaded. Calling Foo.bar() compiles to Foo$.MODULE$.bar(). This lazy, thread-safe initialization is guaranteed by the JVM's own classloading semantics (a class's static initializer runs at most once, under a lock), which is why Scala singletons don't need the double-checked-locking boilerplate a hand-written Java singleton does.

The apply "factory function" trick (Circle(2.0) instead of new Circle(2.0)) is pure syntax sugar with no runtime magic: Circle(2.0) just compiles to Circle.apply(2.0), a call to a method the companion object defines (by convention, one that calls new Circle(2.0) internally). The compiler treats f(args) as f.apply(args) whenever f isn't itself callable — this same rule is what makes list(3) work for indexing into a List.

Cheat sheet

Concept Syntax Purpose
Class class Foo(val x: Int) Blueprint for objects, with constructor params as fields
Auxiliary constructor def this(...) = this(...) Alternate ways to build an instance
Singleton object Foo Exactly one instance, static-like members
Companion object object Foo next to class Foo Factory methods, constants tied to the class
apply def apply(...) = ... Lets Foo(...) work without new
private private val x = ... Restricts visibility to the class (+ companion)

🔀 See this in another language

Exercise

Write a class Temperature(val celsius: Double) with a method toFahrenheit: Double. Give it a companion object with two factory methods: fromCelsius(c: Double): Temperature and fromFahrenheit(f: Double): Temperature (the latter converts to Celsius internally). Add a no-arg auxiliary constructor that defaults to 0.0 Celsius. Test all three ways of constructing a Temperature and print their Fahrenheit values.