Skip to content
Zbyněk Šlajchrt edited this page Jun 12, 2015 · 4 revisions

6. Multiple Dimensions

Motivation

In the previous lesson we abstracted the contact printer by introducing the ContactPrinter dimension trait. There were also two implementations of this dimension, but more could have been created, of course. The modelled system - the contact with printing capabilities - became a one-dimensional system having cardinality 2. While it may be vital to keep thinking in terms of dimensions when designing more complex systems, in traditional programming languages, including Scala, adding more dimensions to the system makes it difficult to maintain and control. Moreover, statically typed languages cannot cope with the so-called combinatorial explosion resulting from the fact that all combinations of fragments from all dimensions must be type-checked in compile time, what requires that all combinations must be declared manually one by one in the code, whereas the number of such combinations grows exponentially with the number of dimensions.

In this tutorial we will see how Morpheus embraces multidimensional design and how tackles with the limitation described above.

Adding more dimensions

Let's refactor the current chat application. We should separate the printing style from the output device used to print the contact. This output device can be abstracted by the following dimension.

@dimension
trait OutputChannel {
  def printText(text: String): Unit
}

There will be two implementing fragments, one printing on the standard output and the other storing the output text to a memory buffer.

@fragment
trait StandardOutputChannel extends OutputChannel {
  override def printText(text: String): Unit = print(text)
}

@fragment
trait MemoryOutputChannel extends OutputChannel {

  val outputBuffer = new StringBuilder()

  override def printText(text: String): Unit = outputBuffer.append(text)
}

Next, we will modify the printer fragments in such a way that they will become dependent on the OutputChannel and use the channel to print the contact textual representation. To express the dependency we will use the self-type of the fragment trait.

@fragment
trait ContactRawPrinter extends ContactPrinter {
  this: Contact with OutputChannel =>

  def printContact(): Unit = {
    printText(s"$firstName $lastName $nationality $male")
  }
}

@fragment
trait ContactPrettyPrinter extends ContactPrinter {
  this: Contact with OutputChannel =>

  def printContact(): Unit = {
    printText(
      s"""
         First Name: $firstName
         Second Name: $lastName
         Male: $male
         Nationality: $nationality
      """)
  }
}

Nothing extraordinary has been shown so far, of course. What we have developed is a two-dimensional system with cardinalities (2, 2). Now we are going to compose a contact morph. The composition of a morph corresponds to a coordinate in the combinatorial space defined by the two dimensions ContactPrinter and OutputChannel consisting of four elements. Let's assume that the required combination of fragments is given by coordinates (style, channel) given as the program system properties:

val (style, channel) = (System.getProperty("style", "raw"), System.getProperty("channel", "standard"))

Next, we have to capture all combinations of the coordinates and create the corresponding morph.

val contact = (style, channel) match {
   case ("pretty", "memory") => singleton[Contact with ContactPrettyPrinter with MemoryOutputChannel].!
   case ("raw", "memory") => singleton[Contact with ContactRawPrinter with MemoryOutputChannel].!
   case ("pretty", "standard") => singleton[Contact with ContactPrettyPrinter with StandardOutputChannel].!
   case ("raw", "standard") => singleton[Contact with ContactRawPrinter with StandardOutputChannel].!
}

It must be clear now, that adding more dimensions would lead to exponential growth of the case statements. There must be other way to avoid this combinatorial explosion.

The or disjunctor

Morpheus provides the or disjunctor to eliminate the above-mentioned explosion. It allow us to express all combinations in one morph type, thus in one statement. The four statements from the previous paragraph can be expressed this way:

val contactKernel = singleton[Contact
   with (ContactRawPrinter or ContactPrettyPrinter)
   with (StandardOutputChannel or MemoryOutputChannel)]

Adding a new dimension would result in adding another line to the previous statement. Thus the combinatorial explosion is over.

The statement expresses four alternatives. So the question is, which one will be chosen when instantiating the morph? The answer is: the so-called left-most one, which consists of the left-most fragments from each dimension. Here it gives the Contact with ContactRawPrinter with StandardOutputChannel alternative.

val contact = contactKernel.!

contact.firstName = "Pepa"
contact.lastName = "Novák"
contact.male = true
contact.nationality = Locale.CANADA

contact.printContact()

Obtaining morph's alternative

Morphs implement one additional trait org.morpheus.MorphMirror. This trait provides some reflection capabilities by which one can look into the actual composition of the morph. One of such methods is myAlternative returning a list of fragment holders representing the constitution of the morph.

println(contact.myAlternative)

It will print this:

List(org.cloudio.morpheus.tutor.chat.frag.step1.Contact, org.cloudio.morpheus.tutor.chat.frag.step4.ContactRawPrinter, org.cloudio.morpheus.tutor.chat.frag.step4.StandardOutputChannel)

The asMorphOf reshaper

A morph can be easily reshaped to another alternative from the space of alternatives by means of the asMorphOf macro.

val memoryPrettyPrinter = asMorphOf[ContactPrettyPrinter with MemoryOutputChannel](contact)
memoryPrettyPrinter.printContact()

The type parameter of the asMorphOf macro specifies the fragment composition of the desired alternative form, which the new morph will assume. This is also the return type of the macro. The other argument can be either a morph or a kernel. The macro constructs a new kernel whose morph type corresponds to the macro's type argument. The new kernel thus constructs only one alternative, which is actually the alternative of the morph returned by the asMorphOf macro.

The macro verifies that the type argument matches the morph or kernel passed as the other argument. For example, the following statement will not compile, since the ContactSerializer fragment is not in the type model of morph contact.

// it won't compile
val printerWithSerializer = asMorphOf[ContactRawPrinter with ContactSerializer](contact)

This macro is useful in situations when only a subset of all alternatives makes sense for a certain part of the application. Otherwise, i.e. as long as we need to access all alternatives arbitrarily, using this macro would again lead to the combinatorial explosion. We will learn how to pick an arbitrary alternative elegantly just by specifying its coordinates in the next lesson.

The select matcher

How can we determine whether a morph implements certain fragments? The select macro provides this functionality. Its type argument specifies the type query, i.e. the fragments, which we match with the morph. The other argument is the morph. It returns Option[F], where F is the type query.

select[MemoryOutputChannel](memoryPrettyPrinter) match {
   case None =>
   case Some(memChannel) =>
      println(memChannel.outputBuffer)
}

The select macro performs some compile-time analysis during which it checks whether the type query is within the scope of the morph's type model. For example the following statement will not compile, since the type model of the morph does not contain the ContactSerializer fragment.

select[ContactSerializer](contact) // it won't compile

Neither will compile this statement, since the model type of the morph is ContactPrettyPrinter with MemoryOutputChannel, which does not contain ContactRawPrinter.

select[ContactRawPrinter](memoryPrettyPrinter) // it won't compile

On the other hand, the following select statement will compile despite Contact is not explicitly mentioned in the morh type.

select[Contact with ContactPrettyPrinter](memoryPrettyPrinter)

The reason, why the macro allows it, is the fact, that ContactPrettyPrinter depends on Contact and thus any morph having ContactPrettyPrinter in its alternative must also have Contact in it.