The chapter opens by recalling the two notions that justify the whole course, and by connecting them to programming languages.
| Specification | Model (in MDE) |
|---|---|
| Description of the behaviour required to fulfil a requirement. | A specification evolved from requirements to implementation. |
A software model is defined as "an informative representation of a software system": it represents the shape of the concepts to capture, and a selection of what to neglect. The second half of the sentence is the part to remember: models always neglect something, otherwise they would be the system itself. And models that capture behaviour are actually systems, possibly parts of an actual system: they may be somewhat executable, where "executable" can mean emulation, simulation or animation.
How does one achieve the combined use? The deck contrasts two approaches. The transformation-based (classical) MDE approach goes from model to code by model-to-code transformations, possibly in multiple steps. The language-based (agile) MDE approach, which this chapter follows, uses languages that encompass the whole spectrum from specification to implementation. For that to work, a programming language must offer four features, each justified by an MDE concern:
| Feature | MDE reason |
|---|---|
| Syntactically flexible | Model specification is typically done by DSLs. |
| Highly declarative | Models abstract from implementation details. |
| Strongly typed | Models need to be certified as correct. |
| Scalable | The same language is used from specification to implementation. |
Scala is presented as a natural choice for this job, while the deck notes that other high-level modern languages could be used as well. The specific goals of the lecture are to introduce or recap Scala mechanisms that the rest of the course will use, to pave the way towards the edge of advanced programming, and to introduce additional verification techniques. The progression of the deck is: dealing with abstract data types (functions, methods, algebraic data types, alias and opaque types, modules as strategies); dealing with contextual programming (contextual abstractions, ad-hoc polymorphism and type classes); the ScalaCheck tool as an application of property-based verification; and pervasive declarativity through monads, up to a fully declarative MVC application.
The four features give a one-line answer to "why Scala in this course": models are written as strongly typed, highly declarative, DSL-shaped Scala code that scales from specification to implementation. Keep this table attached to the course arc: this chapter builds the specification half, and Part C shows the same language used to specify systems under uncertainty rather than only programs.
Scala is a strongly typed, sophisticated and scalable language that mixes OOP and FP, although the course uses it for functional programming; the deck adds that ideally any Scala API can admit a Java facade. Three uses are named: as a powerful declarative language (functions, modules, types, property checking, monadic constructions); for DSL support, since flexible syntax supports the language-oriented engineering of models; and for specifying system behaviour, capturing the constraints and capabilities of software components.
A module is just a set of definitions. The first example packs the mechanisms used all over the course: recursion with pattern matching, currying, records, extension methods, and the flexible syntax that makes method calls read like DSL statements.
object Functions extends App:
// a function (i.e. method), using recursion and case-match
def factorial(n: Int): Int = n match
case 0 | 1 => 1
case _ => n * factorial(n - 1)
println(factorial(5)) // 120
// a function (i.e. method), using currying, and passing a function
def applyManyTimes[A](initial: A, n: Int)(f: A => A): A = n match
case 0 => initial
case _ => applyManyTimes(f(initial), n - 1)(f)
println(applyManyTimes(0, 10)(i => i + 2)) // 20
println(applyManyTimes(0, 10)(_ + 2)) // equivalent formulation
// a record data type
case class Point2D(x: Double, y: Double)
// using extension method
extension (p: Point2D) def multiply(d: Double): Point2D = p match
case Point2D(x, y) => Point2D(d * x, d * y)
// flexible syntax for method calls
println(multiply(Point2D(10, 20))(1.5)) // standard, with currying
println(Point2D(10, 20).multiply(1.5)) // OO style
println(Point2D(10, 20) multiply 1.5) // binary operator style
println: // context-oriented
Point2D(10, 20).multiply:
1.5
The deck shows the same program in Java 21+ using switch expressions, record types and static methods: the point is not that Scala is exotic, but that these mechanisms are now mainstream, and Scala lets them compose. The Java version is not reproduced here; the equivalence is the lesson.
The primer then builds two small data types with enum, the functional-mechanisms toolbox the chapter will reuse: modules as sets of definitions, algebraic (sum and product) data types, genericity, functions and methods, pattern matching, recursion, and extension methods.
object Optionals:
enum Optional[A]:
case Just(a: A)
case None() // here parens are needed because of genericity
object Optional:
extension [A](opt: Optional[A])
def isEmpty(): Boolean = opt match
case None() => true
case _ => false
def orElse[B >: A](orElse: B): B = opt match
case Just(a) => a
case _ => orElse
def map[B](f: A => B): Optional[B] = opt match
case Just(a) => Just(f(a))
case _ => None()
The notable case is sequences, i.e. lists, as a recursively defined algebraic data type with extension methods that are structural recursions over it.
object Sequences:
enum Sequence[E]:
case Cons(head: E, tail: Sequence[E])
case Nil()
object Sequence:
def of[A](n: Int, a: A): Sequence[A] =
if (n == 0) then Nil[A]() else Cons(a, of(n - 1, a))
extension (s: Sequence[Int])
def sum: Int = s match
case Cons(h, t) => h + t.sum
case _ => 0
extension [A](s: Sequence[A])
def map[B](mapper: A => B): Sequence[B] = s match
case Cons(h, t) => Cons(mapper(h), t.map(mapper))
case Nil() => Nil()
def filter(pred: A => Boolean): Sequence[A] = s match
case Cons(h, t) if pred(h) => Cons(h, t.filter(pred))
case Cons(_, t) => t.filter(pred)
case Nil() => Nil()
These little data types are not just examples: they are the models that the rest of the chapter and the next one will test, extend and compose. The deck immediately shows them under test in ScalaTest, with one test block per operation.
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers.*
import Sequences.*
import Sequence.*
class SequenceTest extends AnyFunSuite:
test("Sequence correctly sums"):
Cons(10, Cons(20, Cons(30, Nil()))).sum shouldBe 60
Nil().sum shouldBe 0
test("Sequence correctly maps"):
Cons(10, Cons(20, Nil())).map(_ + 1) shouldBe Cons(11, Cons(21, Nil()))
Cons(10, Cons(20, Nil())).map(_.toString) shouldBe Cons("10", Cons("20", Nil()))
Nil[Int]().map(_ + 1) shouldBe Nil()
test("Sequence correctly filters"):
Cons(10, Cons(20, Nil())).filter(_ <= 10) shouldBe Cons(10, Nil())
Cons(10, Cons(20, Nil())).filter(_ <= 9) shouldBe Nil()
Nil[Int]().filter(_ <= 10) shouldBe Nil()
test("Sequence correctly creates with of"):
Sequence.of(3, "a") shouldBe Cons("a", Cons("a", Cons("a", Nil())))
Sequence.of(0, 10) shouldBe Nil()
These are example-based tests: each one pins a handful of specific inputs. The whole point of the chapter's trajectory is that a specification should instead be checked against general rules, and the machinery for that (properties, generators, and the ScalaCheck tool) is built out of the very same mechanisms shown here. That is section 7 of this chapter and the opening of chapter 7.
Abstract data types (ADTs) are introduced as a cornerstone of programming and software engineering: define a type by name + operations + constructors, abstracting and hiding concrete implementations, which could be replaced one another. The idea is that a module can define abstract types and make their concretion opaque to users, and then provide constructors and operations (as methods) with known mutual coherence.
| Implication | What it buys |
|---|---|
| Actual implementation completely hidden | Clients depend only on the contract, so the representation can change freely. |
| Well-known specification technique for systems in general | An ADT is a specification: it names the behaviour, not the bits. |
| Alternate organisation w.r.t. explicit algebraic data types | The constructor cases are not exposed; this is the OOP-flavoured route. |
The construct that realises opacity in Scala 3 is an alias type with opaqueness: inside object O, write opaque type T = TImpl, with genericity if needed. From the outside, the association of T to TImpl is invisible.
The deck specifies the ADT of lists in a (semi-)formal language, to be used at design time. Note the four compartments — type, constructors, operations, axioms — and that the axioms are equations relating operations to constructors.
type:
List[A]
constructors:
cons : A x List[A] => List[A]
nil : List[A]
operations:
map[B] : List[A] x (A => B) => List[B]
concat : List[A] x List[A] => List[A]
axioms:
map(nil, f) = nil
map(cons(h, t), f) = cons(f(h), map(t, f))
concat(nil, l) = l
concat(cons(h, t), l) = cons(h, concat(t, l))
Three remarks close the specification. Some constructs or operations may be declared private, visible only inside the module. The axioms are complete if, applied left-to-right, they always end up in a single result made by constructors only — every expression of the language reduces to a canonical term. And such a specification can be turned into a logic-based or functional implementation, which is exactly what the code below does.
The specification above is realised with an opaque type whose implementation is a private enum. The users of the module see only the constructors and the operations.
And here is the decisive property of opacity: because Sequence is defined opaque, a client cannot even write a Cons directly, and cannot pattern-match on the representation. The usage example shows that the only way to build a value is through cons and nil.
The second example reuses the algebraic Sequence as the hidden representation of a set ADT, with fromSequence removing duplicates as it copies, and the usual set operations.
object SetADT:
opaque type Set[A] = Sequence[A]
def fromSequence[A](seq: Sequence[A]): Set[A] = seq match
case Cons(h, t) => Cons(h, fromSequence(t.remove(h)))
case Nil() => Nil()
def union[A](s1: Set[A], s2: Set[A]): Set[A] = s2 match
case Cons(h, t) => Cons(h, union(s1.remove(h), t))
case Nil() => s1
def intersection[A](s1: Set[A], s2: Set[A]): Set[A] = s1 match
case Cons(h, t) if s2.contains(h) => Cons(h, intersection(t, s2.remove(h)))
case Cons(_, t) => intersection(t, s2)
case Nil() => Nil()
extension [A](s: Set[A])
def contains(a: A): Boolean = s match
case Cons(h, t) if h == a => true
case Cons(_, t) => t.contains(a)
case Nil() => false
def remove(a: A): Set[A] = s.filter(_ != a)
def toSequence(): Sequence[A] = s
@main def trySetADT =
import SetADT.*
val s1: Set[Int] = fromSequence(Cons(10, Cons(20, Cons(10, Cons(30, Nil())))))
val s2: Set[Int] = fromSequence(Cons(10, Cons(11, Nil())))
// val s3: Set[Int] = Cons(10, Nil()) // because Set is defined opaque
println(s1.toSequence()) // (10, 20, 30)
println(s2.toSequence()) // (10, 11)
println(union(s1, s2).toSequence()) // (10, 20, 30, 11)
println(intersection(s1, s2).toSequence()) // (10)
The commented line is the whole lesson in miniature: the user of the ADT cannot say Cons(10, Nil()) for a Set, because the association to the representation is invisible. fromSequence(Cons(10, ...)) works only because fromSequence lives inside the module.
The deck closes the section with an honest comparison, because the course uses both styles.
| ADTs (opaque, module-based) | Exposed algebraic data types |
|---|---|
| A traditional software engineering approach. | Somewhat more reflect the Scala approach in libraries. |
| Promote abstraction and modularity. | More handy to flexibly add algorithms outside the definition. |
| Treat operations and constructors uniformly. | Constructors are public cases of an enum. |
| From there one more naturally evolves to type classes. | From there one more naturally evolves to the OOP approach. |
From this point on, the deck assumes the algebraic data types version for sequences and streams, while the rest of the lecture keeps discussing from ADTs — and it is the ADT route that leads to type classes, which is precisely where the chapter is heading.
The next step separates the concept of a module from its implementation: modules can be given a type, and many different implementations of the same module type may exist. The implications are stated as a list worth memorising:
The constructs are minimal: a trait ModuleType with method signatures, an object ModuleImpl extends ModuleType, and functions that take the module type as a parameter.
object MathModules:
// type of a module
trait MathModuleType:
def factorial(n: Int): Int
def exp(base: Double, power: Int): Double
// non-tail recursive solutions without input checks
object BasicMathModule extends MathModuleType:
override def factorial(n: Int): Int =
if n == 0 then 1 else n * factorial(n - 1)
override def exp(base: Double, power: Int): Double =
if power == 0 then 1 else base * exp(base, power - 1)
object ProductionMathModule extends MathModuleType:
override def factorial(n: Int): Int =
@scala.annotation.tailrec
def _fact(n: Int, temp: Int): Int =
if n == 0 then temp else _fact(n - 1, temp * n)
n match
case _ if n >= 0 => _fact(n, 1)
override def exp(base: Double, power: Int): Double =
@scala.annotation.tailrec
def _exp(p: Int, temp: Double): Double =
if p == 0 then temp else _exp(p - 1, temp * base)
power match
case _ if base >= 0 => _exp(power, 1)
The two implementations trade off aspects: the basic one is direct but recurses non-tail-recursively and performs no input checks; the production one is tail-recursive and guards its inputs. The client, however, does not care which one it gets: it programs against the module type.
@main def tryMathModule =
import MathModules.*
// probability of having x successes over n trials, where each success has prob. p
// abstracting from specific implementation of the math module
def binomialProbability(mm: MathModuleType)(n: Int, x: Int, p: Double): Double =
mm.factorial(n) / mm.factorial(x) / mm.factorial(n - x) *
mm.exp(p, x) * mm.exp(1 - p, n - x)
// probability of 6 heads on 10 coin tosses
println:
binomialProbability(BasicMathModule)(10, 6, 0.5)
println:
binomialProbability(ProductionMathModule)(10, 6, 0.5)
The same pattern applied to the sequence ADT yields a module type that includes the abstract type Sequence[A] member, and two implementations: a direct one over a private enum, and a "smart" one backed by the standard library List.
object Sequences:
trait SequenceADT:
type Sequence[A]
def cons[A](a: A, s: Sequence[A]): Sequence[A]
def nil[A](): Sequence[A]
def map[A, B](s1: Sequence[A], f: A => B): Sequence[B]
def concat[A](s1: Sequence[A], s2: Sequence[A]): Sequence[A]
object BasicSequenceADT extends SequenceADT:
private enum SequenceImpl[A]:
case Cons(a: A, t: Sequence[A])
case Nil()
import SequenceImpl.*
opaque type Sequence[A] = SequenceImpl[A]
override def cons[A](a: A, s: Sequence[A]) = Cons(a, s)
override def nil[A](): Sequence[A] = Nil()
override def concat[A](s1: Sequence[A], s2: Sequence[A]) = s1 match
case Cons(a, s) => Cons(a, concat(s, s2))
case _ => s2
override def map[A, B](s1: Sequence[A], f: A => B) = s1 match
case Cons(a, s) => Cons(f(a), map(s, f))
case _ => Nil()
object ScalaListSequenceADT extends SequenceADT:
opaque type Sequence[A] = List[A]
override def cons[A](a: A, s: Sequence[A]) = a :: s
override def nil[A](): Sequence[A] = List()
override def concat[A](s1: Sequence[A], s2: Sequence[A]) = s1 ++ s2
override def map[A, B](s1: Sequence[A], f: A => B): Sequence[B] = s1.map(f)
Usage is where the abstraction pays: each implementation can be imported, and a function can be written once against the module type and applied to both.
@main def trySequencesADTModule =
import Sequences.*
val basicSequenceADT: SequenceADT = BasicSequenceADT
val scalaListSequenceADT: SequenceADT = ScalaListSequenceADT
{
import basicSequenceADT.*
val s1: Sequence[Int] = cons(10, cons(20, cons(30, nil())))
println(concat(s1, s1)) // (10, 20, 30, 10, 20, 30)
println(map(s1, _ >= 20)) // (false, true, true)
}
{
import scalaListSequenceADT.*
val s2: Sequence[Int] = cons(10, cons(20, cons(30, nil())))
println(concat(s2, s2)) // (10, 20, 30, 10, 20, 30)
println(map(s2, _ >= 20)) // (20, 30)
}
def sequenceOps(sADT: SequenceADT): Unit =
import sADT.*
val s1: Sequence[Int] = cons(10, cons(20, cons(30, nil())))
println(concat(s1, s1)) // (10, 20, 30, 10, 20, 30)
println(map(s1, _ >= 20)) // (false, true, true)
sequenceOps(BasicSequenceADT)
sequenceOps(ScalaListSequenceADT)
The two map outputs differ: over the direct implementation, map(s1, _ >= 20) maps every element to a boolean, giving (false, true, true); over the List implementation, the deck prints (20, 30). The second line appears to be a typo in the source deck for a filter call — the point of the example is the identical concat behaviour under both imports, not the output of that particular line.
The sentence "ADT axioms could become properties to hold in any implementation" is the hinge of the whole Part B. Axioms are equations over the contract; implementations must respect them; and the natural way to check that any implementation respects them is property-based testing over the module type — the subject of chapter 7. If you present one exam task about verified ADTs, you can quote this sentence as the bridge between the two chapters.
Contextual abstraction is the idea that certain inputs of a function are actually just "context", so one might want to provide implicit ways of defining and passing them. The deck draws three implications:
The constructs are a pair: a using clause marks parameters as contextual, and a given clause defines canonical terms that the compiler can pass implicitly.
The running example is max over a sequence, which needs a strategy for ordering. First version: the ordering is a plain curried parameter.
def max[T](seq: Sequence[T])(ordering: (T, T) => Boolean): T = seq match
case Cons(h1, Cons(h2, t)) =>
val m = max(Cons(h2, t))(ordering)
if ordering(h1, m) then h1 else m
case Cons(h1, Nil()) => h1
@main def tryContextualParameters() =
// input and context are passed in the same way
println:
max(Cons(10, Cons(30, Cons(20, Nil()))))(_ > _) // 30
println:
max(Cons(10, Cons(30, Cons(20, Nil()))))(_ < _) // 10
Second version: the strategy becomes a module type, so that the ordering logic lives in a named, reusable object.
trait OrderingModule[T]:
def greater(t1: T, t2: T): Boolean
def max[T](seq: Sequence[T])(ordering: OrderingModule[T]): T = seq match
case Cons(h1, Cons(h2, t)) =>
val m = max(Cons(h2, t))(ordering)
if ordering.greater(h1, m) then h1 else m
case Cons(h1, Nil()) => h1
object MyStandardIntOrdering extends OrderingModule[Int]:
def greater(t1: Int, t2: Int): Boolean = t1 > t2
object MyStandardStringOrdering extends OrderingModule[String]:
def greater(t1: String, t2: String): Boolean = t1 < t2
Third version: the ordering becomes a contextual parameter. It is still passed explicitly, but the syntax marks it as context, and recursive calls re-pass it with using.
def max[T](seq: Sequence[T])(using ordering: OrderingModule[T]): T = seq match
case Cons(h1, Cons(h2, t)) =>
val m = max(Cons(h2, t))(using ordering)
if ordering.greater(h1, m) then h1 else m
case Cons(h1, Nil()) => h1
@main def tryUsingContextualParameters =
println:
max(Cons(10, Cons(30, Cons(20, Nil()))))(using MyStandardIntOrdering)
println:
max(Cons("10", Cons("30", Cons("20", Nil()))))(using MyStandardStringOrdering)
Fourth and final version: canonical terms are declared with given, and the call sites stop mentioning the context altogether. Note the two syntaxes: a given can reuse a previous definition, or be defined on-site with a with block.
object GivenContextualParameters:
import UsingContextualParameters.*
// defining a canonical term using a previous definition
given OrderingModule[Int] = MyStandardIntOrdering
// defining a canonical term on-site: an alternate syntax
given OrderingModule[String] with
def greater(t1: String, t2: String): Boolean = t1 < t2
// those given are here "in scope"
@main def tryGivenContextualParameters =
println:
max(Cons(10, Cons(30, Cons(20, Nil()))))
println:
max(Cons("10", Cons("30", Cons("20", Nil()))))
@main def tryImportGiven =
import UsingContextualParameters.*
// importing all givens
import GivenContextualParameters.given
// importing a specific given
// import GivenContextualParameters.g
// importing just * would not work!
println:
max(Cons(10, Cons(30, Cons(20, Nil()))))
val ord = summon[OrderingModule[Int]] // gives MyStandardIntOrdering
Two details are easy to miss and worth the exam: importing just * does not bring givens into scope — you need .given (or name the specific given); and summon[OrderingModule[Int]] recovers the canonical term currently in scope, which is the primitive that type classes will use.
Watch the call sites shrink across the four versions. In the last one, max(...) carries only its true input: the strategy is context, and context is resolved by the compiler, not spelled out by the programmer. This is the declarativity mechanism of the chapter: you specify abstract behaviour, and key "platform" details are just context.
A context bound is a constraint on a type variable for generic methods, stating that an instantiation is admissible if a given (generic) context for it is available. The deck draws out the implications:
using clause taking a Ctx[T] parameter;Ctx[_] can be interpreted as an "a posteriori" extension of type T — namely, a type class;The constructs: trait GenericCtx[T] defines the type class; def meth[T: GenericCtx](arg: Type) declares a method with ad-hoc polymorphism; and summon[GenericCtx[T]] recovers the witness implementation of the extension.
The max example, one more time, is now written with a context bound. It can be called on T only if an Ordered[T] is available in scope — and the commented line shows the failure mode: there is no Ordered[Double].
object ContextBound:
// Ordered as a type class
trait Ordered[T]:
def greater(t1: T, t2: T): Boolean
// max with ad-hoc polymorphism
// it can be called on T only if an Ordered[T] is available in scope
def max[T: Ordered](seq: Sequence[T]): T = seq match
case Cons(h1, Cons(h2, t)) =>
val m = max(Cons(h2, t)) // can avoid passing context
if summon[Ordered[T]].greater(h1, m) then h1 else m
case Cons(h1, Nil()) => h1
// defining the "Ordered" extension for Int
given Ordered[Int] with
def greater(t1: Int, t2: Int): Boolean = t1 > t2
// defining the "Ordered" extension for String
given Ordered[String] with
def greater(t1: String, t2: String): Boolean = t1 > t2
@main def tryContextBound =
// the necessary given are already in scope
println:
max(Cons(10, Cons(30, Cons(20, Nil()))))
println:
max(Cons("a", Cons("c", Cons("d", Nil()))))
// println(max(Cons(1.0, Cons(3.0, Cons(5.0, Nil()))))) // not working
The second example is a pretty-printing type class. Note how the algorithm lives in a companion object that requires only the type class, and how an extension method makes show() read as if it were a member of every type that has a given instance.
object Showables:
// the Showable type class
trait Showable[T]:
def show(t: T): String
// algorithms / operations on showables
object Showable:
extension [A: Showable](a: A)
def show(): String = summon[Showable[A]].show(a)
def showPair[A: Showable, B: Showable](t: (A, B)): String = t match
case (a, b) => "(" + a.show() + ", " + b.show() + ")"
def showSequence[A: Showable](seq: Sequence[A]): String = seq match
case Sequence.Cons(h, t) => "| " + h.show() + showSequence(t)
case Sequence.Nil() => ":"
object ShowableGivenInstances:
import Showables.*, Showable.*
// canonical terms for Int, String, Student
given Showable[Int] with
def show(i: Int): String = "" + i
given Showable[String] with
def show(s: String): String = s
case class Student(name: String, id: Int)
given Showable[Student] with
def show(s: Student): String = s match
case Student(n, i) => "stud(" + n.show() + ", " + i.show() + ")"
@main def tryShowable =
import Showables.*, Showable.*
import ShowableGivenInstances.{*, given}
// note it seems like we have actually dynamically extended Int, String...
println(10.show())
println("hello!".show())
println(Student("mario", 201).show())
It looks like 10.show() dynamically extended Int. Nothing was modified: the extension method is available exactly because a Showable[Int] given is in scope. This is the OOP-flavoured counterpart of the ADT route: the type stays closed and concise, and capability is attached to it a posteriori, per type, in an ad-hoc way.
The section ends with a kind ladder that the monad machinery of chapter 7 will need. A 0-kinded type is a standard type; a 1-kinded type is generic over a 0-kinded type; a 2-kinded type is generic over a 1-kinded type, i.e. over a type constructor like Optional[_] or Sequence[_].
// 0-kinded types: standard types
trait FactorialModuleType:
def factorial(n: Int): Int
object BasicFactorialModule extends FactorialModuleType:
def factorial(n: Int): Int = if n == 0 then 1 else n * factorial(n - 1)
val f = BasicFactorialModule.factorial(5)
// 1-kinded types: generic over a 0-kinded type
trait Showable[T]:
def show(t: T): String
object ShowableInt extends Showable[Int]:
def show(i: Int): String = "" + i
val s = ShowableInt.show(5)
// 2-kinded types: generic over a 1-kinded type
trait Filterable[T[_]]:
def filter[A](t: T[A])(f: A => Boolean): T[A]
object FilterableOptional extends Filterable[Optional]:
def filter[A](t: Optional[A])(f: A => Boolean): Optional[A] = t match
case Just(a) if f(a) => Just(a)
case _ => None()
val opt = FilterableOptional.filter(Just(11))(_ > 10)
The deck shows the same ladder once more with extension methods, where each kind level exposes its capability as an extension (5.factorial(), 5.show(), Just(11).filter(...)): the two formulations are orthogonal, and both will appear in the course.
The ladder is the examinable spine of the chapter: be able to rewrite max in the four styles and to say what each step adds. Then add the punchline that links to the course: the Monad[M[_]] type class of chapter 7 is a 2-kinded type class, and the effect systems of chapter 8 are built exactly on this combination of module types and type classes.
The outline of deck 04 continues with three topics that the study path assigns to chapter 7: for-yield and the monad type class; an application, namely ScalaCheck and property-based testing; and a State-monadic little MVC. They are the payoff of everything this chapter built, so it is worth stating here how the pieces connect before handing them over.
Monad[M[_]] type class — unit plus flatMap as an extension — generalises exactly the extension-method machinery of this chapter to type constructors, and re-implements map for free.ScalaCheck itself is mentioned in the goals of this chapter because it "can be used also to property-check Java code", which the lab below exploits in the JAVA-SCALA-CHECK task; but its machinery (Gen[T] as a monad, Arbitrary[T], the forAll API, the default of 100 generated elements) is fully treated in chapter 7, since generators are themselves monadic constructions.
Software models and programming languages: software models are specifications of software systems, useful for design and verification; models capturing system behaviour are systems per se, to be properly engineered; and programming languages like Scala are very good at programming software models. An example formal model: ADTs — type, constructors, operators and axioms, turned into Scala ADTs with traits and opaque types, and verified with ScalaCheck/ScalaTest. From ADTs to type classes and monads — type classes factorise the empowerment of existing, simple ADTs; monads are a flexible way to compose functional abstractions; and one application is writing an entire MVC application.
https://github.com/mviroli/asmd24-public-04-advanced-programminghttps://www.scalatest.org/user_guidehttps://scalacheck.org/documentation.htmlhttps://softwarefoundations.cis.upenn.edu/vfa-current/ADT.htmlThe general goals are to become operative with ScalaCheck/ScalaTest, to play with ADTs and ScalaCheck, to play with monads, and to engineer with monads.
SequenceCheck) run correctly. The rest of the code should be implemented inside or using the package scala.lab04.test/scala/lab04; note that currently only map is checked, while sum and filter are only implemented — complete the properties for those two operations, and try to implement a new operation such as flatMap with its own ScalaCheck tests. Then explore the parameters: how many tests are generated by default, how to change the number of tests, how to modify the random seed, and what other parameters exist. Finally compare ScalaCheck with ScalaTest: can ScalaTest perform parameterised tests, and what are the key differences between the two frameworks?| Task | What it asks |
|---|---|
| ADT-VERIFIER | Define a formal ADT for sets with essential operations: union, intersection, contains. Examine the current implementation in scala.lab04.SetADT and complete the property-based tests in SetADTCheck by adding the missing algebraic properties (commutativity, associativity, idempotence) for union and intersection — e.g. A ∪ B = B ∪ A — by implementing cross-property tests such as the relationship between union and intersection, and by ensuring the properties reflect the mathematical axioms of sets. Once the tests are complete, implement an alternative version of SetADT using a tree-based structure and adapt the tests minimally to work with the new implementation. |
| JAVA-SCALA-CHECK | Use ScalaCheck as a property-based testing tool for Java code, leveraging Scala's Java interoperability. Create a simple immutable Java class (e.g. Point2D with x, y coordinates and methods like distanceTo, translate, rotate), write a comprehensive ScalaCheck suite verifying mathematical properties such as distance symmetry (a.distanceTo(b) == b.distanceTo(a)), the triangle inequality, and rotation invariants; extend to testing Java standard library classes such as java.util.ArrayList or java.util.TreeSet; and reflect on the advantages and limitations of using ScalaCheck as a cross-language testing DSL. |
| MONAD-VERIFIER | Define ScalaCheck properties for the monad axioms, prove that some of the monads given during the lesson actually satisfy them, and derive a general methodology to structure those tests. Needs the monad material of chapter 7. |
| MVC-ENGINEER | Start from the given monadic MVC application and extend it to a more complex application, e.g. the DrawNumberGame, staying fully monadic. Explore up to which complexity one can reach, whether a simple MVC application with a reactive GUI is possible, and whether a game loop can be framed into a fully monadic application. Needs the State-monadic MVC material of chapter 7. |
| ADVANCED-FP-LLM | LLMs and ChatGPT can arguably help in writing, improving, completing, implementing and reverse-engineering ADT specifications, Scala ADTs, and monadic specifications. Check whether this is actually the case. Directly reuses the LLM and AI-assisted engineering perspective of chapters 4–5. |
ADT-VERIFIER and JAVA-SCALA-CHECK are the tasks that belong to this chapter, and they combine beautifully: the first formalises the axioms of a set ADT and checks them, the second shows the same machinery applied across languages. A strong presentation pairs them with the "axioms become properties" idea: the ADT is the model, the properties are the axioms, and the alternative tree-based implementation demonstrates that the properties hold in any implementation of the contract. If you also do ADVANCED-FP-LLM, you have a direct line to Part A's thesis that AI output quality is bounded by your ability to evaluate it — evaluating an ADT specification is exactly what the axioms make possible.
A specification is a description of the behaviour required to fulfil a requirement; a model in MDE is a specification evolved from requirements to implementation. A software model is an informative representation of a software system, capturing the shape of concepts and a selection of what to neglect — models always neglect something, otherwise they would be the system itself. The two uses are design, where the model is the template for an implementation (e.g. a UML class diagram), and verification, where the model satisfies certain properties that ideally transfer to the final system (e.g. a Petri net guaranteeing no more than one token in a place). The combined use is a model that satisfies properties plus a strategy to turn it into the design of a system whose implementation satisfies the same properties.
Syntactically flexible, because model specification is typically by DSLs; highly declarative, because models abstract from implementation details; strongly typed, because models need to be certified as correct; and scalable, because the same language must serve from specification to implementation.
An ADT defines a type by name, operations and constructors, abstracting and hiding concrete implementations which could be replaced one another. A module defines abstract types and makes their concretion opaque to users, providing constructors and operations with known mutual coherence. The construct is an opaque alias: inside object O, opaque type T = TImpl, with genericity if needed — the association to TImpl is invisible from outside.
Type, constructors, operations, axioms. The axioms are complete if, applied left-to-right, they always end up in a single result made by constructors only. Some constructs or operations may be declared private, and the specification can be turned into a logic-based or functional implementation.
ADTs are the traditional software engineering approach: they promote abstraction and modularity, treat operations and constructors uniformly, and from there one evolves more naturally to type classes. Exposed algebraic data types reflect the Scala approach in libraries, are more handy for flexibly adding algorithms outside the definition, and from there one evolves more naturally to the OOP approach. The chapter assumes the algebraic version for sequences and streams, but keeps discussing from ADTs.
A module type separates the concept of a module from its implementation, so many implementations may exist trading off various aspects; implementations can be passed to functions to make them more abstract; and an application may decide which implementation to use each time. For ADTs this is very useful because the ADT axioms could become properties to hold in any implementation — the bridge to property-based testing.
A using clause marks parameters as contextual: they are still passed explicitly (with using at the call site) but the syntax separates context from input. A given clause defines canonical terms — either by reusing a previous definition (given OrderingModule[Int] = MyStandardIntOrdering) or on-site (given OrderingModule[String] with ...) — so call sites can omit the context altogether. summon[OrderingModule[Int]] recovers the canonical term in scope, the primitive that type classes use internally. Note that importing just * does not bring givens into scope: you need .given.
A context bound is a constraint on a type variable for generic methods stating that an instantiation is admissible if a given (generic) context for it is available, written [T: Ctx]. The context Ctx[_] can be interpreted as an "a posteriori" extension of type T — a type class — and the whole mechanism is a programmable way of supporting ad-hoc polymorphism: def max[T: Ordered](seq) compiles for Int and String (which have givens) and fails for Double (which has none).
10.show() works without modifying Int.The companion object of Showable defines an extension method extension [A: Showable](a: A) def show(): the extension exists for any type with a Showable instance, and its body is summon[Showable[A]].show(a). Because given Showable[Int] is in scope, the compiler makes the extension available on Int and summons the instance to render it. It looks like a dynamic extension of Int, but nothing was modified: capability is attached a posteriori, per type.
A 0-kinded type is a standard type, e.g. FactorialModuleType. A 1-kinded type is generic over a 0-kinded type, e.g. Showable[T]. A 2-kinded type is generic over a 1-kinded type, i.e. over a type constructor, e.g. Filterable[T[_]] instantiated as Filterable[Optional]. The same ladder can be expressed with extension methods, and the Monad[M[_]] type class of the next chapter is 2-kinded.
By default, generators produce 100 elements, and the number is configurable. Step 2 asks you to examine the existing SequenceCheck tests, complete properties for sum and filter (only map is checked), implement and test a new operation such as flatMap, explore how to change the number of tests, the random seed and other parameters, and compare ScalaCheck with ScalaTest — including whether ScalaTest can perform parameterised tests and what the key differences are.
ADT-VERIFIER and JAVA-SCALA-CHECK belong to this chapter; MONAD-VERIFIER and MVC-ENGINEER need chapter 7's monad material; ADVANCED-FP-LLM bridges back to chapters 4 and 5. ADT-VERIFIER requires formalising a set ADT (union, intersection, contains), completing SetADTCheck with the algebraic properties (commutativity, associativity, idempotence), adding cross-property tests, ensuring the properties reflect the mathematical axioms of sets, and then implementing an alternative tree-based SetADT adapting the tests minimally.