algorithmscalasingle-responsibility-principle

Better code, one method one responsibility


Let's say you're writing an algorithm for some collection, for example method 'contains' for some List. The code could look something like this (this is very simple, just for the sake of the example):

def betterContains(list: List[String], element: String): Boolean = {
   if (list.isEmpty) false 
   else if (list.head == element) true
   else betterContains(list.tail, element)
}

Imagine a more complex algorithm, examples would be searching for elements in trees, graphs, etc. And for some reason you add code for logging:

def betterContains(list: List[String], element: String): Boolean = {
   if (list.isEmpty) {
      log.info("The element was not found in the list.")
      false
   } 
   else if (list.head == element) {
      log.info("Yes! found it!")
      true
   }
   else {
      log.info(s"Still searching, ${list.tail.size} elements pending")
      betterContains(list.tail, element)
   }
}

Then, let's say you are adding code for saving some progress data in a text file. At the end, you will have one method that is doing 3 things:

If the developer decides to use a new log library he will have to make changes to the method implementation. Also if he decides to change the way the data is being saved in the text file, again he will have to make changes to the method implementation.

Is there any approach to avoid this? I mean, I only want to make changes to the method if I found a better way (a better algorithm) to find the element in the list. It seems to me that the algorithm does not meet the single responsibility principle, it is doing more than one thing.


Solution

  • It seems to me that the algorithm does not meet the single responsibility principle, it is doing more than one thing.

    Right. This is one of the reasons why for logging, auditing, security checks, performance monitoring, exception handling, caching, transaction management, persistence, validation etc. i.e. for different kinds of additional orthogonal behavior people use instrumentation of their code

    What are the possible AOP use cases?

    Instrumentation can be runtime (runtime reflection, runtime annotations, aspect-oriented programming, java agents, bytecode manipulation), compile-time (macros, compile-time annotation processors, compiler plugins), pre-compile-time/build-time (source generation, Scalameta/SemanticDB, sbt source generators, boilerplate templating) etc.

    For example you can instrument your code at compile time with a macro annotation logging branching ("if-else")

    import scala.annotation.{StaticAnnotation, compileTimeOnly}
    import scala.language.experimental.macros
    import scala.reflect.macros.blackbox
    
    @compileTimeOnly("enable macro annotations")
    class logBranching extends StaticAnnotation {
      def macroTransform(annottees: Any*): Any = macro LogBranchingMacro.impl
    }
    
    class LogBranchingMacro(val c: blackbox.Context) {
      import c.universe._
    
      val printlnT = q"_root_.scala.Predef.println"
      def freshName(prefix: String) = TermName(c.freshName(prefix))
    
      val branchTransformer = new Transformer {
        override def transform(tree: Tree): Tree = tree match {
          case q"if ($cond) $thenExpr else $elseExpr" =>
            val condStr = showCode(cond)
            val cond2   = freshName("cond")
            val left2   = freshName("left")
            val right2  = freshName("right")
    
            val (optLeft1, optRight1, cond1, explanation) = cond match {
              case q"$left == $right" =>
                (
                  Some(this.transform(left)),
                  Some(this.transform(right)),
                  q"$left2 == $right2",
                  q""" ", i.e. " + $left2 + "==" + $right2 """
                )
              case _ =>
                (
                  None,
                  None,
                  this.transform(cond),
                  q""" "" """
                )
            }
    
            val backups = (cond, optLeft1, optRight1) match {
              case (q"$_ == $_", Some(left1), Some(right1)) =>
                Seq(
                  q"val $left2  = $left1",
                  q"val $right2 = $right1"
                )
              case _ => Seq()
            }
    
            val thenExpr1 = this.transform(thenExpr)
            val elseExpr1 = this.transform(elseExpr)
    
            q"""
              ..$backups
              val $cond2 = $cond1
              $printlnT("checking condition: " + $condStr + $explanation + ", result is " + $cond2)
              if ($cond2) $thenExpr1 else $elseExpr1
            """
    
          case _ => super.transform(tree)
        }
      }
    
      def impl(annottees: Tree*): Tree = annottees match {
        case q"$mods def $tname[..$tparams](...$paramss): $tpt = $expr" :: Nil =>
          val expr1 = branchTransformer.transform(expr)
          q"$mods def $tname[..$tparams](...$paramss): $tpt = $expr1"
    
        case _ => c.abort(c.enclosingPosition, "@logBranching can annotate methods only")
      }
    }
    
    // in a different subproject
    
    @logBranching
    def betterContains(list: List[String], element: String): Boolean = {
      if (list.isEmpty) false
      else if (list.head == element) true
      else betterContains(list.tail, element)
    }
    
      //   scalacOptions += "-Ymacro-debug-lite"
    //scalac: def betterContains(list: List[String], element: String): Boolean = {
    //  val cond$macro$1 = list.isEmpty;
    //  _root_.scala.Predef.println("checking condition: ".$plus("list.isEmpty").$plus("").$plus(", result is ").$plus(cond$macro$1));
    //  if (cond$macro$1)
    //    false
    //  else
    //    {
    //      val left$macro$5 = list.head;
    //      val right$macro$6 = element;
    //      val cond$macro$4 = left$macro$5.$eq$eq(right$macro$6);
    //      _root_.scala.Predef.println("checking condition: ".$plus("list.head.==(element)").$plus(", i.e. ".$plus(left$macro$5).$plus("==").$plus(right$macro$6)).$plus(", result is ").$plus(cond$macro$4));
    //      if (cond$macro$4)
    //        true
    //      else
    //        betterContains(list.tail, element)
    //    }
    //}
    
    betterContains(List("a", "b", "c"), "c")
    
    //checking condition: list.isEmpty, result is false
    //checking condition: list.head.==(element), i.e. a==c, result is false
    //checking condition: list.isEmpty, result is false
    //checking condition: list.head.==(element), i.e. b==c, result is false
    //checking condition: list.isEmpty, result is false
    //checking condition: list.head.==(element), i.e. c==c, result is true
    

    For example Scastie instruments user-entered code with Scalameta

    https://github.com/scalacenter/scastie/tree/master/instrumentation/src/main/scala/com.olegych.scastie.instrumentation

    Another approach to add additional behavior in functional programming is effects, e.g. monads. Read about logging monad, Writer monad, logging with free monads etc.