Scala traits for compile speed

Hi, I have a theory about using Scala traits to speed up incremental compiles and want to get some feedback from fellow Scala devs if I’m on the right track or on a wild goose chase.

My theory is that using a trait to decouple dependencies from the implementation will speed up incremental recompilation because when the trait doesn’t change but the implementation details do, the trait’s dependencies won’t need to be recompiled.

Let me give an example to clarify, in case of confusion. Suppose that originally we have a service UserService and a dependency UserController (using some made-up libraries to implement a REST API):

// UserService.scala

class UserService {
  def get(id: Long): User = ???
}

case class User(id: Long, name: String)

// UserController.scala

class UserController(userService: UserService) {
  @Path("/users/:id")
  def get(id: Long): Future[Json] = {
    val user = userService.get(id)
    Json.obj("id" -> user.id, "name" -> user.name)
  }
}

Now if I change the implementation of UserService#get this will cause a recompile of UserController. But, if I change UserService into a trait and decouple its implementation:

// UserService.scala

trait UserService {
  def get(id: Long): User
}

class UserServiceImpl extends UserService {
  def get(id: Long): User = ???
}

… and then change the implementation of UserServiceImpl, then UserController won’t be recompiled because its direct dependency, UserService, did not change.

If I’m right, and I have something like 40 backend services, then potentially I’m looking at a lot of compile time savings?

3 Likes

Yeah, you’re on the right track – this is actually a very standard technique for most object-oriented languages. I originally learned this approach with C++ 20 or so years ago: compilers were much slower, and this was basically the only way to build a system with even remotely sane compile times.

It also helps you avoid circular dependencies, which can cause disastrously bad compile times.

So in general: yes, this is a common and often-recommend way to do things.

2 Likes

Awesome, thank you!

Separating interface from implementation is a generally recommended practice. But wouldn’t the incremental compilation machinery be smart enough not to recompile dependencies when only the implementation of a method changes?

Verifying that only the implementation, not the signature, has changed, is non-trivial. For example:

def m(a: A): B = …

How do you know that A and B still refer to the same types? They could be defined anywhere. They could depend on many other types, which you would have to trace back. Using traits reduces the number of type dependencies.

A build tool cannot be expected to answer that question. There would have to be a compiler feature to do that. I’m not sure it gains much compared to a full compile.

1 Like

A quick experiment seems to confirm my suspicion though. Only changes to the interface (names, type signatures) seem to trigger recompilation of dependent files.

BTW self-types compile faster than extends, and recompile less often, so if you have multiple layers of traits you might keep that in mind.

That’s interesting. Can you quickly identify a reason for that?

My guess would be that when you extend, you’re baking the inheritance into the cake, so the compiler has a bunch of work to do. It has to generate the combined result, with everything that entails. OTOH when you have a self-type, it’s just a declaration that is only enforced when you use it elsewhere.