Syntactic sugar in Scala

on waitingforcode.com

Syntactic sugar in Scala

When a programming language provides operator overloading, the learning curve increases most of the time because of the syntactic sugar it brings. After all more of operations will be expressed as not meaningful (at least in first approach) symbols. Scala also comes with its own syntactic sugar that can be applied in a lot of places: sequences, functions or conversion.

This post presents the syntactic sugar in Scala. It's divided in 5 parts where each one presents use cases hidden behind the symboles. The syntactic sugar for underscore is deliberately omitted in this post. If you're interested about it, please read the post about Underscore role in Scala.

Syntactic sugar in sequences

Probably the most visible place using syntactic sugar are sequences. they're often an intrinsic part of the most of applications. In Scala the operations on them can be done in "classical" manner, through explicitly named methods. But it's also possible to use the following operators:

  • + - adds one or multiple elements (depending on the sequence type) to given set or map:
      it("should add new element into the list with + operator") {
        val letters = Set("a", "b", "c")
        
        val newLetters = letters + "d"
        
        newLetters should have size 4
        newLetters should contain only("a", "b", "c", "d")
      }
    
      it("should add new entry to the map with + operator") {
        val lettersWithNumbers = Map("a" -> 1, "b" -> 2, "c" -> 3)
    
        val newLettersWithNumbers = lettersWithNumbers + ("d" -> 4)
    
        newLettersWithNumbers should have size 4
        newLettersWithNumbers should contain only(("a", 1), ("b", 2), ("c", 3), ("d", 4))
      }
      
  • - - the opposite of above, it removes given element(s) from the sequence:
      it("should remove one element from the set with - operator") {
        val letters = Set("a", "b", "c")
    
        val newLetters = letters - "a"
    
        newLetters should have size 2
        newLetters should contain only("b", "c")
      }
      it("should remove the entry from the map with - operator") {
        val lettersWithNumbers = Map("a" -> 1, "b" -> 2, "c" -> 3)
    
        val newLettersWithNumbers = lettersWithNumbers - "a"
    
        newLettersWithNumbers should have size 2
        newLettersWithNumbers should contain only(("b", 2), ("c", 3))
      }
      
  • ++ and ++: - concatenates 2 sequences:
      it("should concatenate both sequences with ++ operator") {
        val letters1 = List("a", "b")
        val letters2 = List("c", "d")
    
        val newLetters = letters1 ++ letters2
    
        newLetters should have size 4
        newLetters should contain only("a", "b", "c", "d")
      }
      it("should concatenate both sequences with alternative ++ operator") {
        val letters1 = List("a", "b")
        val letters2 = List("c", "d")
    
        val newLetters = letters1 ++: letters2
    
        newLetters should have size 4
        newLetters should contain only("a", "b", "c", "d")
      }
      it("should concatenate both maps and eliminate duplicates") {
        val lettersWithNumbers1 = Map("a" -> 1, "b" -> 2)
        val lettersWithNumbers2 = Map("b" -> 3, "c" -> 4)
    
        val newLettersWithNumbers = lettersWithNumbers1 ++ lettersWithNumbers2
    
        newLettersWithNumbers should have size 3
        newLettersWithNumbers should contain only(("a", 1), ("b", 3), ("c", 4))
      }
      
  • -- - mostly used with sets and maps. It creates a new sequence by removing the elements from -- method's set:
      it("should remove multiple elements from Seq") {
        val letters = Set("a", "b", "c")
    
        val onlyOneLetterSet = letters -- Seq("a", "b")
    
        onlyOneLetterSet should have size 1
        onlyOneLetterSet("c") shouldBe true
      }
      it("should remove multiple keys from the map") {
        val lettersWithNumbers = Map("a" -> 1, "b" -> 2, "c" -> 3, "d" -> 4)
    
        val lettersWithoutAB = lettersWithNumbers -- Seq("a", "b")
    
        lettersWithoutAB should have size 2
        lettersWithoutAB should contain only(("c", 3), ("d", 4))
      }
      
  • +: and :+ - respectively prepends or appends an element to given sequence:
      it("should prepend new element to the list") {
        val letters = List("b", "c")
    
        val newLetters = "a" +: letters
    
        newLetters should have size 3
        newLetters should contain inOrder ("a", "b", "c")
      }
      it("should append new element to the list") {
        val letters = List("a", "b")
    
        val newLetters = letters :+ "c"
    
        newLetters should have size 3
        newLetters should contain inOrder ("a", "b", "c")
      }
      
  • /: and :\ - both apply a binary operation on all elements in the collection. The former one does it from the left and the latter from the right side of the sequence. Both are the synonyms for foldLeft and foldRight methods:
      it("should apply binary operation from the left to the sequence") {
        val letters = Seq("a", "b", "c")
    
        val lettersText = ("letters are:" /: letters)((previousText, letter) => s"${previousText} ${letter}")
    
        lettersText shouldEqual "letters are: a b c"
      }
      it("should apply binary operation from the right to the sequence") {
        val letters = Seq("a", "b", "c")
    
        val lettersText = (letters :\ "letters are:")((letter, previousText) => s"${previousText} ${letter}")
    
        lettersText shouldEqual "letters are: c b a"
      }
      it("should apply binary operation from the left to the map") {
        val lettersWithNumbers = Map("a" -> 1, "b" -> 2, "c" -> 3, "d" -> 4)
    
        val mapStringified = ("Map content:" /: lettersWithNumbers)((previousText, entry) =>
          s"${previousText} (${entry._1}, ${entry._2})")
    
        mapStringified shouldEqual "Map content: (a, 1) (b, 2) (c, 3) (d, 4)"
      }
      it("should apply binary operation from the right to the map") {
        val lettersWithNumbers = Map("a" -> 1, "b" -> 2, "c" -> 3, "d" -> 4)
    
        val mapStringified = (lettersWithNumbers :\ "Map content:")((entry, previousText) =>
          s"${previousText} (${entry._1}, ${entry._2})")
    
        mapStringified shouldEqual "Map content: (d, 4) (c, 3) (b, 2) (a, 1)"
      }
      
  • & - gets the intersection (common elements) between the first and the second set:
      it("should compute the intersection of 2 sets with &") {
        val letters1 = Set("a", "b", "c")
        val letters2 = Set("c", "d", "e")
    
        val delta = letters1 & letters2
    
        delta should have size 1
        delta should contain only("c")
      }
      
  • &~ - computes the difference between 2 different sets (elements in the first set that are missing in the second one):
      it("should compute the difference between sets with &-") {
        val letters1 = Set("a", "b", "c")
        val letters2 = Set("c", "d", "e")
    
        val delta = letters1 &~ letters2
    
        delta should have size 2
        delta should contain only("a", "b")
      } 
      

In the example of interchangeable operations as :\ and /: or :+ and +: we can observe that the manipulated sequence is either on the left or the right side of the expression. Considering these operators as the synonyms of methods would be misleading since the parameters should be always on the same place. It's worth to know that it's not the case.

*fix notation

Methods calls in Scala are made with the dot notation, known from Java:

val concatenatedLetters = letters.mkString(", ")

But other and less verbose solutions exist. The first one is the infinix notation. Thanks to it we can ommit the dots in the methods/fields calls:

it("should be called on symbolic method name") {
  val letters = Set("a", "b")

  val newLetters = letters + "c"

  newLetters should have size 3
  newLetters should contain only("a", "b", "c")
}
it("should be called on alphanumerical method name") {
  val numbers = 1 to 4

  val evenNumbers = numbers filter (_%2 == 0)

  evenNumbers should have size 2
  evenNumbers should contain only(2, 4)
}

Another kind of notation shortcuts is the postfix notation. It lets us to invoke the parameterless methods without dots and parentheses:

it("should call parameterless method with postfix notation") {
  val letters = Seq("a", "b", "c")

  val firstLetter = letters head

  firstLetter shouldEqual "a"
}

Even though infinix notation brings some space savings, it's not advised to write the whole applications with it. Normally it's a good candidate for operators (aka symbolic method names, see the examples from the previous section) or internal DSL languages (e.g. Scalatest's matchers). But in other places (alphanumeric notes in production code, boolean expressions) it's good to keep things simple and use dot notation. Why it's a bad idea to use the infinix expressions everywhere is shown in the following code where we must wrap the conditional expression with additional parentheses to avoid the compilation error:

it("should wrap the conditional expression with parenthesis") {
  val letters = Seq("a", "b", "c", "d")
  // Here we want to check if given letter is in the sequence
  // We use for that an if-else expression to show that sometimes
  // we may need to wrap the infinix expressions that not always will
  // lead to the improved readability
  val containChecker: (String) => Boolean = (letter: String) => {
    if (!(letters contains letter)) {
      false
    } else {
      true
    }
  }

  val resultForA = containChecker("a")
  val resultForZ = containChecker("z")

  resultForA shouldBe true
  resultForZ shouldBe false
}

Regarding to postfix notation it's discouraged by the compiler and its use must be explicitly enabled with scala.language.postfixOps feature. Otherwise a warning similar to the following one is returned at the compilation time:

Warning:(169, 33) postfix operator head should be enabled
by making the implicit value scala.language.postfixOps visible.
This can be achieved by adding the import clause 'import scala.language.postfixOps'
or by setting the compiler option -language:postfixOps.
See the Scaladoc for value scala.language.postfixOps for a discussion
why the feature should be explicitly enabled.
      val firstLetter = letters head

Moreover postfix notation doesn't guarantee the correct interpretation of the code by the compiler. Sometimes the compiler can consider the line following the postfix notation as the integrate part of it:

val letters = Seq("a", "b", "c")
var flag = false

val firstLetter = letters head
flag = true

Such code won't compile because of the error:

Error:(170, 33) value update is not a member of String
      val firstLetter = letters head

Apply method

Another syntax shortcut in Scala is the invocation of the apply(...) method. It can be invoked either in classical dot notation or in less usual way, considering the object directly as a function:

describe("apply syntactic sugar") {
  it("should be called without the object") {
    class Letter(letter: String) {
      def apply(introduction: String): String = s"${introduction} ${letter}"
    }
    val aLetter = new Letter("a")

    val introduction = aLetter("This is")

    introduction shouldEqual "This is a"
  }
  it("should be used to create a new instance from the companion object") {
    class Letter(val letter: String) {}
    object Letter {
      def apply(number: Int): Letter = new Letter(s"${number}")
    }

    val letterFromInt = Letter(1)

    letterFromInt.letter shouldEqual "1"
  }
}

Functions and tuples definition

Another kind of syntactic sugar can be found with the function and tuples definition. For the former ones we could define their types as FunctionX where X is the supported arity. However a less verbose and more commonly used way exists:

describe("functions definitions") {
  it("should define a shorthand version of function type") {
    def convertToString(number: Int): String = s"${number}"

    val intToStringConverter: (Int) => String = convertToString _
    val oneAsString = intToStringConverter(1)

    oneAsString shouldEqual "1"
  }
  it("should define a verbose function type") {
    def convertToString(number: Int): String = s"${number}"

    val intToStringConverter: Function1[Int, String] = convertToString _
    val oneAsString = intToStringConverter(1)

    oneAsString shouldEqual "1"
  }
  it("should define an inlined function") {
    val intToStringConverter: (Int) => String = (number: Int) => s"${number}"
    
    val oneAsString = intToStringConverter(1)

    oneAsString shouldEqual "1"
  }
  it("should define a shorthand version of function with no parameters") {
    def returnIdleState(): String = "idle"

    val idleStateGenerator: () => String = returnIdleState _
    val idleState = idleStateGenerator()

    idleState shouldEqual "idle"
  }
}

Another similar use concerns the destructing bind of tuples that can be directly extracted from the underlying sequence:

describe("tuple destruction bind") {
  it("should assign the tuple content to new vars") {
    val (letter1, letter2, _) = ("a", "b", "c")

    letter1 shouldEqual "a"
    letter2 shouldEqual "b"
  }
}

Symbols

Another less common use of Scala's syntactic sugar is the apostrophe (') sign that can be used to create the instances of Symbol class:

describe("Symbol instance") {
  it("should be created from the apostrophe") {
    val symbol = 'A

    symbol.name shouldEqual "A"
  }
}

As shown in this post, Scala, by implementing the operator overriding, provides a lot of syntactic sugar that can sometimes replace the original methods (the first section talks about it) or reduce the verbosity of the code base (an example can be found in the last section with the function shortening). However, as usual, you should be careful about the use of the syntactic sugar. After all the .foldLeft(...) invocation is immediately more meaningful than /: symbols. Moreover, sometimes the syntactic sugar expressions may lead to compilation or execution problems as it was proven in the part about the *fix notations.

Share, like or comment this post on Twitter: