One of the features of good code is the separation of concerns. Developers should aim to make classes and their methods responsible for one and only one thing. This helps in testing, maintaining, and simply understanding code better. Remember: simple is always better.
However, it is inevitable that when writing real software, we will need instances of some classes within other ones in order to achieve certain functionalities. In other words, once our building blocks are nicely separated, they would have dependencies in order to perform their functionality. What we are talking about here really boils down to dependency injection. Self types provide a way to handle these dependencies in an elegant way. In this section, we will see how to use them and what they are good for.
Self types allow us to easily separate code in our applications, and then require it from other places. Everything gets clearer with an example, so let's have a look at one. Let's assume that we want to be able to persist information into a database:
trait Persister[T] { def persist(data: T) }
The persist
method will do some transformations on the data and then insert it in our database. Of course, our code is well written, so the database implementations are separated. We have the following for our database:
trait Database[T] { def save(data: T) } trait MemoryDatabase[T] extends Database[T] { val db: mutable.MutableList[T] = mutable.MutableList.empty override def save(data: T): Unit = { System.out.println("Saving to in memory database.") db.+=:(data) } } trait FileDatabase[T] extends Database[T] { override def save(data: T): Unit = { System.out.println("Saving to file.") } }
We have a base trait and then some concrete database implementations. So how do we pass our database to Persister
? It should be able to call the save
method defined in the database. Our possibilities include the following:
Database
in Persister
. This would, however, also make Persister
an instance of Database
, and we don't want this. We will show why later.Database
in Persister
and use it.We are trying to see how self types work here, so let's use this approach. Our Persister
interface will change to the following:
trait Persister[T] { this: Database[T] => def persist(data: T): Unit = { System.out.println("Calling persist.") save(data) } }
Now we have access to the methods in database and can call the save
method inside Persister
.
Naming the self type
In the preceding code, we included our self type using the following statement: this: Database[T] =>
. This allows us to access the methods of our included types directly as if they were methods of the trait that includes them. Another way of doing the same here is by writing self: Database[T] =>
instead. There are many examples out there that use the latter approach, which is useful to avoid confusion if we need to refer to this
in some nested trait or class definitions. Calling the methods of the injected dependencies using this approach, however, would require the developer to use self.
in order to gain access to the required methods.
The self type requires any class that mixes Persister
in, to also mix Database
in. Otherwise, our compilation will fail. Let's create classes to persist to memory and database:
class FilePersister[T] extends Persister[T] with FileDatabase[T] class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T]
Finally, we can use them in our application:
object PersisterExample { def main(args: Array[String]): Unit = { val fileStringPersister = new FilePersister[String] val memoryIntPersister = new MemoryPersister[Int] fileStringPersister.persist("Something") fileStringPersister.persist("Something else") memoryIntPersister.persist(100) memoryIntPersister.persist(123) } }
Here is the output of our program:
Calling persist. Saving to file. Calling persist. Saving to file. Calling persist. Saving to in memory database. Calling persist. Saving to in memory database.
What self types do is different than inheritance. They require the presence of some code, and thus allow us to split a functionality nicely. This can make a huge difference in maintaining, refactoring, and understanding a program.
In real applications, we might require more than one component using a self type. Let's show this in our example with a History
trait that could potentially keep track of changes to roll back at some point. Ours will just do printing:
trait History { def add(): Unit = { System.out.println("Action added to history.") } }
We need to use this in our Persister
trait, and it will look like this:
trait Persister[T] { this: Database[T] with History => def persist(data: T): Unit = { System.out.println("Calling persist.") save(data) add() } }
Using the with
keyword, we can add as many requirements as we like. However, if we just leave our code changes there, it will not compile. The reason for this is that we now must mix History
in every class that uses Persister
:
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History
And that's it. If we now run our code, we will see this:
Calling persist. Saving to file. Action added to history. Calling persist. Saving to file. Action added to history. Calling persist. Saving to in memory database. Action added to history. Calling persist. Saving to in memory database. Action added to history.
In the preceding example, we had a requirement for the History
trait, which has an add()
method. What would happen if the methods in different components have the same signatures and they clash? Let's try this:
trait Mystery { def add(): Unit = { System.out.println("Mystery added!") } }
We can now use this in our Persister
trait:
trait Persister[T] { this: Database[T] with History with Mystery => def persist(data: T): Unit = { System.out.println("Calling persist.") save(data) add() } }
Of course, we will change all the classes that mix Persister
in:
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery
If we try to compile our application, we will see that it results in a failure with the following messages:
Error:(47, 7) class FilePersister inherits conflicting members: method add in trait History of type ()Unit and method add in trait Mystery of type ()Unit (Note: this can be resolved by declaring an override in class FilePersister.) class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery ^ Error:(48, 7) class MemoryPersister inherits conflicting members: method add in trait History of type ()Unit and method add in trait Mystery of type ()Unit (Note: this can be resolved by declaring an override in class MemoryPersister.) class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery ^
Luckily, the error messages also contain information that tells us how we can fix the problem. This is absolutely the same case that we saw earlier while using traits, and we can provide the following fix:
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery { override def add(): Unit ={ super[History].add() } } class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery { override def add(): Unit ={ super[Mystery].add() } }
After running the example, we will see the following output:
Calling persist. Saving to file. Action added to history. Calling persist. Saving to file. Action added to history. Calling persist. Saving to in memory database. Mystery added! Calling persist. Saving to in memory database. Mystery added!
What we saw in our preceding examples was a pure example of dependency injection. We required one component to be available in another one through self types.
The cake design pattern relies completely on self types. It encourages engineers to write small and simple components, which declare and use their dependencies. After all the components in an application are programmed, they can be instantiated inside a common component registry and made available to the actual application. One of the nice advantages of the cake design pattern is that it actually checks during compile time whether all the dependencies would be satisfied.
We will dedicate a complete section on the cake design pattern later in this book, where we will provide more details about how the pattern can actually be wired up, what advantages and drawbacks it has, and so on.
In the previous section, we said that we don't want to use inheritance in order to get access to the Database
methods. Why is it? If we had made Persister
extend Database
, this would mean that it would become a database itself (is-a relationship). However, this is not correct. It uses a database in order to achieve its functionality.
Inheritance exposes a subclass to the implementation details of its parent. This, however, is not always desired. According to the authors of Design Patterns: Elements of Reusable Object-Oriented Software, developers should favor object composition over class inheritance.
If we use inheritance, we would also leak functionality to subclasses that we do not want. Let's see the following code:
trait DB { def connect(): Unit = { System.out.println("Connected.") } def dropDatabase(): Unit = { System.out.println("Dropping!") } def close(): Unit = { System.out.println("Closed.") } } trait UserDB extends DB { def createUser(username: String): Unit = { connect() try { System.out.println(s"Creating a user: $username") } finally { close() } } def getUser(username: String): Unit = { connect() try { System.out.println(s"Getting a user: $username") } finally { close() } } } trait UserService extends UserDB { def bad(): Unit = { dropDatabase() } }
This could be a real-life scenario. Because this is how inheritance works, we would get access to dropDatabase
in UserService
. This is something we do not want, and we can fix it using self types. The DB
trait stays the same. Everything else changes to the following:
trait UserDB { this: DB => def createUser(username: String): Unit = { connect() try { System.out.println(s"Creating a user: $username") } finally { close() } } def getUser(username: String): Unit = { connect() try { System.out.println(s"Getting a user: $username") } finally { close() } } } trait UserService { this: UserDB => // does not compile // def bad(): Unit = { // dropDatabase() //} }
As the comments in the code show, in this last version of the code, we do not have access to the DB
trait methods. We can only call the methods of the type we require, and this is exactly what we wanted to achieve.
18.225.72.245