24 | Expand diagram: Effectful Functional Programming - a visual intuition
25 |
26 |
27 | 
28 |
29 |
30 |
31 |
32 |
33 | ### Libraries
34 |
35 | The workshop consists of a series of practical exercises using the following open source libraries:
36 | - [Cats](https://typelevel.org/cats/)
37 | - [Eff](https://github.com/atnos-org/eff)
38 | - [Monix](https://monix.io/)
39 | - [Monocle](http://julien-truffaut.github.io/Monocle/)
40 | - [Cats Effect](https://github.com/typelevel/cats-effect)
41 |
42 | ### Use Case
43 |
44 | Each exercise is an alternate implementation of the same use case:
45 |
46 | *Ever had a full disk? Where does the space go? Implement a program that can find the largest N files in a directory tree*
47 |
48 |
49 | ## Setup
50 |
51 | - Wifi/Internet required.
52 |
53 | - You will need Java 8+ and Simple Build Tool (`sbt`) [installed](http://www.scala-sbt.org/release/docs/Setup.html).
54 |
55 | - While SBT will download Scala and the Eff libraries on-demand, this can be a slow process. Before the workshop, it is recommended
56 | to run `sbt update` in the base directory to pre-download the required libraries. This may take a few minutes up to 1 hour,
57 | depending what you have cached locally in `~/.ivy2/cache`.
58 |
59 | - Import the base SBT project into your IDE: [Intellij](https://www.jetbrains.com/help/idea/2016.1/creating-and-running-your-scala-application.html),
60 | [Eclipse ScalaIDE](http://scala-ide.org/) or [Ensime](http://ensime.org/).
61 |
62 | - Or work with any editor and the SBT command line if you prefer.
63 |
64 | *Be warned that IDE presentation compilers don't correctly handle some Eff code*, and may
65 | flag valid code as invalid. Try your code with the full Scala compiler via SBT command line before concluding there is a problem.
66 |
67 | ## Exercises
68 |
69 | The SBT base project contains nine exercise projects, each with a README with instructions to attempt. Each of them contains
70 | a different implementation of a file scanner program.
71 |
72 | It is suggested to do the exercises in this order. The instruction pages are best viewed in a browser; reach them here:
73 | - [Classic](exerciseClassic/README.md) - File Scanning in a classic Scala style
74 | - [Task effect](exerciseTask/README.md) - Using Monix task effect to defer execution
75 | - [Reader effect](exerciseReader/README.md) - Using Reader effect for dependency injection and abstracting the environment
76 | - [Error effect](exerciseError/README.md) - Using Either effect for error handling
77 | - [Writer effect](exerciseWriter/README.md) - Using Writer effect for logging
78 | - [State effect](exerciseState/README.md) - Using State effect to keep track of Symlinks encountered
79 | - [Concurrency](exerciseConcurrency/README.md) - Scanning directories in parallel with applicative traversal
80 | - [Optics](exerciseOptics/README.md) - Using Optics to change the focus of a Reader effect
81 | - [Custom Effects](exerciseCustom/README.md) - Using a custom Filesystem effect
82 |
83 |
84 | There are three types of tasks you'll encounter
85 | - :mag: _Study Code_ Study existing application and test code
86 | - :pencil: _Write Code_ Adding missing code or changing existing code at an indicated line or method.
87 | - :arrow_forward: _Run Code_ Run the file scanner (eg `exercise1/run`) or the unit tests (eg `exercise1/test`) from SBT prompt.
88 |
89 | Each project can be compiled, run or tested separately; errors in one project won't affect the others.
90 |
91 | *Initially, most exercises will not compile and/or run, until you complete the specified tasks. To try running the code,
92 | go to the corresponding `solutions` project. *
93 |
94 | ## Solutions
95 |
96 | There is a [solutions](solutions/) subfolder containing corresponding solution subprojects.
97 |
98 | There is learning value in attempting a hard problem, getting stuck, then reviewing the solution.
99 | Use the solutions if you get blocked!
100 |
101 | ## Using SBT
102 |
103 | Start SBT in the base directory and then operate from the SBT prompt. Invoking each
104 | SBT command from the shell (eg `sbt exercise1/compile`) is slower due to JVM startup costs.
105 | ```
106 | /Users/ben_hutchison/projects/GettingWorkDoneWithExtensibleEffects $ sbt
107 | Getting org.scala-sbt sbt 0.13.13 ...
108 | ..further sbt loading omitted..
109 | >
110 | ```
111 |
112 | To list all exercise- and solution- subproject names:
113 | ```
114 | > projects
115 | ```
116 |
117 | Try running the file scanner (ie `main` method) of subproject `solutionExerciseClassic` on the current directory.
118 | ```
119 | > solutionExerciseClassic/run .
120 | ```
121 |
122 | To compile sources in subproject `exercise1`:
123 | ```
124 | > exerciseClassic/compile
125 | ```
126 |
127 | To run any unit tests (in `src/test/scala/*`) under subproject `exerciseClassic`
128 | ```
129 | > exerciseClassic/test
130 | ```
131 |
132 |
133 | *SBT commands should be scoped to a subproject (eg `exerciseClassic/test`). Running eg `test` at the top level will load
134 | 10 copies of the classes into the SBT JVM, potentially leading to `OutOfMemoryError: Metaspace`*
135 |
136 |
137 | ## "Learn by Doing"
138 |
139 | This project teaches Extensible Effects in practice; what it feels like to code with the Eff framework.
140 |
141 | It doesn't make any attempt to cover
142 | the complex, subtle theory behind Eff, a refinement of 25 years experience of programming with monads, and isn't a complete picture of Eff
143 | by any means. At the time of writing however, there are more resources available covering the theory, than practice, of Eff, including:
144 |
145 | - The original paper [Extensible effects: an alternative to monad transformers](https://www.cs.indiana.edu/~sabry/papers/exteff.pdf)
146 | in Haskell and followup refinement [Freer Monads, More Extensible Effects](http://okmij.org/ftp/Haskell/extensible/more.pdf).
147 |
148 | - [Video presentation](https://www.youtube.com/watch?v=3Ltgkjpme-Y) of the above material by Oleg Kiselyov
149 |
150 | - [The Eff monad, one monad to rule them all](https://www.youtube.com/watch?v=KGJLeHhsZBo) by Eff library creator Eric Torreborre
151 |
152 | - My own video [Getting Work Done with the Eff Monad in Scala](https://www.youtube.com/watch?v=LhGq4HlozV4)
153 |
154 | ## Workshop History
155 |
156 | April 2017
157 |
158 | * Initial version based on Eff 4.3.1, cats 0.9.0 and Monix 2.2.4. Includes 5 exercises introducing
159 | `Reader`, `Either`, `Task` and `Writer` effects.
160 |
161 | * Presented at [Melbourne Scala meetup](https://www.meetup.com/en-AU/Melbourne-Scala-User-Group/events/240544821/)
162 |
163 | May 2017
164 |
165 | * Presented at for [YOW Lambdajam 2017, Sydney](http://lambdajam.yowconference.com.au/archive-2017/ben-hutchison-3/)
166 |
167 | April 2018
168 |
169 | * Upgrade libraries to Eff 5.2, Monic 3.0, cats 1.1, sbt 1.1 and introduce Cats Effect 0.10.1 library to use IO effect
170 | rather than Task, and Monocle 1.5 optics library.
171 |
172 | * Rewrite existing exercises 1 - 5 to reflect updated libraries, slightly changed emphasis. Add three new exercises covering
173 | State, Optics and Custom effects
174 |
175 | May 2018
176 |
177 | * Presented at [Melbourne Scala meetup](https://www.meetup.com/en-AU/Melbourne-Scala-User-Group/)
178 |
179 |
180 |
181 |
182 |
183 |
--------------------------------------------------------------------------------
/build.sbt:
--------------------------------------------------------------------------------
1 | name := "GettingWorkDoneWithExtensibleEffects"
2 |
3 | version := "0.1"
4 |
5 | ThisBuild / scalaVersion := "2.12.5"
6 |
7 | val commonSettings = Seq(
8 | libraryDependencies ++= Seq(
9 | "org.scala-lang.modules" %% "scala-java8-compat" % "0.8.0",
10 | "org.typelevel" %% "cats-core" % "1.1.0",
11 | "org.typelevel" %% "mouse" % "0.17",
12 | "io.monix" %% "monix-eval" % "3.0.0-RC1",
13 | "org.atnos" %% "eff" % "5.2.0",
14 | "org.atnos" %% "eff-monix" % "5.2.0",
15 | "org.atnos" %% "eff-cats-effect" % "5.2.0",
16 | "com.github.julien-truffaut" %% "monocle-core" % "1.5.1-cats",
17 | "com.github.julien-truffaut" %% "monocle-generic" % "1.5.1-cats",
18 | "com.github.julien-truffaut" %% "monocle-macro" % "1.5.1-cats",
19 | "org.specs2" %% "specs2-core" % "4.0.3" % "test"
20 | ),
21 | // to write types like Reader[String, ?]
22 | addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.6"),
23 | //to allow tuple extraction and type ascription in for expressions
24 | addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.2.0"),
25 | // to get types like Reader[String, ?] (with more than one type parameter) correctly inferred for scala 2.12.x
26 | scalacOptions += "-Ypartial-unification",
27 | scalacOptions in Test += "-Yrangepos"
28 | )
29 |
30 |
31 |
32 | lazy val exerciseClassic = (project in file("exerciseClassic")).settings(commonSettings)
33 |
34 | lazy val exerciseTask = (project in file("exerciseTask")).settings(commonSettings)
35 |
36 | lazy val exerciseReader = (project in file("exerciseReader")).settings(commonSettings)
37 |
38 | lazy val exerciseError = (project in file("exerciseError")).settings(commonSettings)
39 |
40 | lazy val exerciseWriter = (project in file("exerciseWriter")).settings(commonSettings)
41 |
42 | lazy val exerciseConcurrent = (project in file("exerciseConcurrent")).settings(commonSettings)
43 |
44 | lazy val exerciseState = (project in file("exerciseState")).settings(commonSettings)
45 |
46 | lazy val exerciseOptics = (project in file("exerciseOptics")).settings(commonSettings)
47 |
48 | lazy val exerciseCustom = (project in file("exerciseCustom")).settings(commonSettings)
49 |
50 |
51 |
52 | lazy val solutionExerciseClassic = (project in file("solutions/exerciseClassic")).settings(commonSettings)
53 |
54 | lazy val solutionExerciseTask = (project in file("solutions/exerciseTask")).settings(commonSettings)
55 |
56 | lazy val solutionExerciseReader = (project in file("solutions/exerciseReader")).settings(commonSettings)
57 |
58 | lazy val solutionExerciseError = (project in file("solutions/exerciseError")).settings(commonSettings)
59 |
60 | lazy val solutionExerciseWriter = (project in file("solutions/exerciseWriter")).settings(commonSettings)
61 |
62 | lazy val solutionExerciseConcurrent = (project in file("solutions/exerciseConcurrent")).settings(commonSettings)
63 |
64 | lazy val solutionExerciseState = (project in file("solutions/exerciseState")).settings(commonSettings)
65 |
66 | lazy val solutionExerciseOptics = (project in file("solutions/exerciseOptics")).settings(commonSettings)
67 |
68 | lazy val solutionExerciseCustom = (project in file("solutions/exerciseCustom")).settings(commonSettings)
69 |
70 |
71 | lazy val exercise1 = (project in file("exercise1")).settings(commonSettings)
72 |
73 | lazy val exercise2 = (project in file("exercise2")).settings(commonSettings)
74 |
75 | lazy val exercise3 = (project in file("exercise3")).settings(commonSettings)
76 |
77 | lazy val exercise4 = (project in file("exercise4")).settings(commonSettings)
78 |
79 | lazy val exercise5 = (project in file("exercise5")).settings(commonSettings)
80 |
81 | lazy val solutionExercise1 = (project in file("solutions/exercise1")).settings(commonSettings)
82 |
83 | lazy val solutionExercise2 = (project in file("solutions/exercise2")).settings(commonSettings)
84 |
85 | lazy val solutionExercise2io = (project in file("solutions/exercise2io")).settings(commonSettings)
86 |
87 | lazy val solutionExercise3 = (project in file("solutions/exercise3")).settings(commonSettings)
88 |
89 | lazy val solutionExercise4 = (project in file("solutions/exercise4")).settings(commonSettings)
90 |
91 | lazy val solutionExercise5 = (project in file("solutions/exercise5")).settings(commonSettings)
92 |
93 | val testSolutions = TaskKey[Unit]("testSolutions", "Run all solution tests")
94 | testSolutions := Seq(
95 | solutionExerciseClassic / Test / test,
96 | solutionExerciseTask / Test / test,
97 | solutionExerciseReader / Test / test,
98 | solutionExerciseError / Test / test,
99 | solutionExerciseWriter / Test / test,
100 | solutionExerciseState / Test / test,
101 | solutionExerciseConcurrent / Test / test,
102 | solutionExerciseOptics / Test / test,
103 | solutionExerciseCustom / Test / test,
104 | ).dependOn.value
105 |
106 |
--------------------------------------------------------------------------------
/exerciseClassic/README.md:
--------------------------------------------------------------------------------
1 | # Classic File Scanner without Eff
2 |
3 | In all the exercises, we will look at variants of a File Scanner. The scanner finds and reports on the largest 10 files
4 | under a directory specified by the user, as well as collecting some stats about how many total files and total bytes are found.
5 |
6 | This first exercise uses regular Scala features without Eff:
7 |
8 | - File system operations are done directly inline, primarily using the `java.nio.file.Files` API provided in Java 8. This
9 | is preferable to the operations offered through the older `java.io.File` API because error conditions are signalled by an
10 | exception rather than simply returning an uninformative `null`.
11 |
12 | - Error handling is by throwing exceptions which will bubble to the top.
13 |
14 | ## Tasks
15 |
16 | ### :arrow_forward: _Run Code_
17 |
18 | Run the scanner on current directory with `solutionExerciseClassic/run .` Does the result seem correct?
19 |
20 | Try it on some larger directory of your computer.
21 |
22 | ### :mag: _Study Code_
23 |
24 | Study the algorithm used by the scanner. The key data structure is a `PathScan`, which contains a sorted list of the
25 | largest N files and their sizes in bytes, plus a count of total files scanned and total bytes across all files.
26 |
27 | Note how the scanner must combine `PathScan`s of differing subdirectories together to yield
28 | a single `PathScan` that summarizes both the *top N* files and *total* files visited. This combine operation has a
29 | [Monoid](http://typelevel.org/cats/typeclasses/monoid.html) structure.
30 |
31 | ### :pencil: _Write Code_
32 |
33 | Complete the Monoid instance for PathScan.
34 |
35 | ### :arrow_forward: _Run Code_
36 |
37 | Run the unit tests with `exerciseClassic/test` to verify your Monoid implementation.
38 |
39 | ### :mag: _Study Code_
40 |
41 | Examine the [ScannerSpec](src/test/scala/scan/ScannerSpec.scala).
42 |
43 | Note how much of the test is involved with creating- and cleaning up- actual files. It would be nice
44 | is we could separate testing the algorithm from the filesystem. How should this be done?
45 |
--------------------------------------------------------------------------------
/exerciseClassic/src/main/scala/scan/Scanner.scala:
--------------------------------------------------------------------------------
1 | package scan
2 |
3 | import java.nio.file._
4 |
5 | import scala.compat.java8.StreamConverters._
6 | import scala.collection.SortedSet
7 |
8 | import cats._
9 | import cats.implicits._
10 |
11 |
12 | object Scanner {
13 |
14 | def main(args: Array[String]): Unit = {
15 | println(scanReport(Paths.get(args(0)), 10))
16 | }
17 |
18 | def scanReport(base: Path, topN: Int): String = {
19 | val scan = pathScan(FilePath(base), topN)
20 |
21 | ReportFormat.largeFilesReport(scan, base.toString)
22 | }
23 |
24 | def pathScan(filePath: FilePath, topN: Int): PathScan = filePath match {
25 | case File(path) =>
26 | val fs = FileSize.ofFile(Paths.get(path))
27 | PathScan(SortedSet(fs), fs.size, 1)
28 | case Directory(path) =>
29 | val files = {
30 | val jstream = Files.list(Paths.get(path))
31 | try jstream.toScala[List]
32 | finally jstream.close()
33 | }
34 | val subscans = files.map(subpath => pathScan(FilePath(subpath), topN))
35 | subscans.combineAll(PathScan.topNMonoid(topN))
36 | case Other(_) =>
37 | PathScan.empty
38 | }
39 |
40 | }
41 |
42 | case class PathScan(largestFiles: SortedSet[FileSize], totalSize: Long, totalCount: Long)
43 |
44 | object PathScan {
45 |
46 | def empty = PathScan(SortedSet.empty, 0, 0)
47 |
48 | def topNMonoid(n: Int): Monoid[PathScan] = new Monoid[PathScan] {
49 | def empty: PathScan = PathScan.empty
50 |
51 | def combine(p1: PathScan, p2: PathScan): PathScan = ???
52 | }
53 |
54 | }
55 |
56 | case class FileSize(path: Path, size: Long)
57 |
58 | object FileSize {
59 |
60 | def ofFile(file: Path) = {
61 | FileSize(file, Files.size(file))
62 | }
63 |
64 | implicit val ordering: Ordering[FileSize] = Ordering.by[FileSize, Long ](_.size).reverse
65 |
66 | }
67 | //I prefer an closed set of disjoint cases over a series of isX(): Boolean tests, as provided by the Java API
68 | //The problem with boolean test methods is they make it unclear what the complete set of possible states is, and which tests
69 | //can overlap
70 | sealed trait FilePath {
71 | def path: String
72 | }
73 | object FilePath {
74 |
75 | def apply(path: Path): FilePath =
76 | if (Files.isRegularFile(path))
77 | File(path.toString)
78 | else if (Files.isDirectory(path))
79 | Directory(path.toString)
80 | else
81 | Other(path.toString)
82 | }
83 | case class File(path: String) extends FilePath
84 | case class Directory(path: String) extends FilePath
85 | case class Other(path: String) extends FilePath
86 |
87 |
88 | //Common pure code that is unaffected by the migration to Eff
89 | object ReportFormat {
90 |
91 | def largeFilesReport(scan: PathScan, rootDir: String): String = {
92 | if (scan.largestFiles.nonEmpty) {
93 | s"Largest ${scan.largestFiles.size} file(s) found under path: $rootDir\n" +
94 | scan.largestFiles.map(fs => s"${(fs.size * 100)/scan.totalSize}% ${formatByteString(fs.size)} ${fs.path}").mkString("", "\n", "\n") +
95 | s"${scan.totalCount} total files found, having total size ${formatByteString(scan.totalSize)} bytes.\n"
96 | }
97 | else
98 | s"No files found under path: $rootDir"
99 | }
100 |
101 | def formatByteString(bytes: Long): String = {
102 | if (bytes < 1000)
103 | s"${bytes} B"
104 | else {
105 | val exp = (Math.log(bytes) / Math.log(1000)).toInt
106 | val pre = "KMGTPE".charAt(exp - 1)
107 | s"%.1f ${pre}B".format(bytes / Math.pow(1000, exp))
108 | }
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/exerciseClassic/src/test/scala/scan/ScannerSpec.scala:
--------------------------------------------------------------------------------
1 | package scan
2 |
3 | import java.io.PrintWriter
4 | import java.nio.file._
5 |
6 | import org.specs2._
7 |
8 | import scala.collection.immutable.SortedSet
9 |
10 | class ScannerSpec extends mutable.Specification {
11 |
12 | "Report Format" ! {
13 | val base = deletedOnExit(Files.createTempDirectory("exerciseClassic"))
14 | val base1 = deletedOnExit(fillFile(base, 1))
15 | val base2 = deletedOnExit(fillFile(base, 2))
16 | val subdir = deletedOnExit(Files.createTempDirectory(base, "subdir"))
17 | val sub1 = deletedOnExit(fillFile(subdir, 1))
18 | val sub3 = deletedOnExit(fillFile(subdir, 3))
19 |
20 | val actual = Scanner.pathScan(FilePath(base), 2)
21 | val expected = new PathScan(SortedSet(FileSize(sub3, 3), FileSize(base2, 2)), 7, 4)
22 |
23 | actual.mustEqual(expected)
24 | }
25 |
26 | def fillFile(dir: Path, size: Int) = {
27 | val path = dir.resolve(s"$size.txt")
28 | val w = new PrintWriter(path.toFile)
29 | try w.write("a" * size)
30 | finally w.close
31 | path
32 | }
33 |
34 | def deletedOnExit(p: Path) = {
35 | p.toFile.deleteOnExit()
36 | p
37 | }
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/exerciseConcurrent/README.md:
--------------------------------------------------------------------------------
1 | # Concurrent Scanning
2 |
3 | The scanning of a directory tree can be done in parallel by processing each subdirectory in separate tasks. Because we have
4 | lifted our program into `Task`s, it is easy to enable concurrent scanning.
5 |
6 | A good general principle for effectful programming is to declare the dependencies between computations (including their effects)
7 | using monad flatMaps, then the runtime can execute as much in parallel near automagically.
8 |
9 | ### :pencil: _Write Code_
10 |
11 | - In `pathScan` we currently use the `traverse` combinator to walk through the subdirectories and recursively invoke
12 | `pathScan` on each of them. Traverse implies that we want to process them strictly in order, but there is a variant
13 | `traverseA` (short for "traverse applicative") which says we can visit them in any order, completing when they have all
14 | been processed. Replace `traverse` with `traverseA` and the tasks can execute concurrently. Nothing more needed!
15 |
16 | ### :arrow_forward: _Run Code_
17 |
18 | Run the tests to verify your task based implementation still gives the correct output.
19 |
20 | Run the scanner on a large directory tree. Do it several times as the results will likely include noise.
21 | Do you see a speed-up from concurrent scanning?
22 |
23 | You may see no improvement, or only a small % improvement (as I did). This may be because your hard drive or SSD has limited capability
24 | to serve requests in parallel.
25 |
26 |
27 | ### :arrow_forward: Run Code_
28 |
29 | By default Monix batches the execution of a series of Tasks serially in the same thread to avoid thread context switches.
30 | The `BatchedExecution(32)` configuration in the Scanner specifies that 32 tasks should be executed by a thread before
31 | releasing control and returning to the configured Monix `Scheduler`.
32 |
33 | When tasks are structurally independent of each other, as is expressed by using `traverseA`, the batch size affects the
34 | degree of concurrency that will be enabled when the program executes. Too much, and a threads can context switch wastefully.
35 | (This is what happens with Scala `Future` and is the reason why Monix tasks typically run faster). Too little, and the available
36 | parallelism in the underlying work may not be achieved.
37 |
38 | Monix defaults to a batch size 1024. For tasks which do IO such as the Scanner, this may be too high. The value 32 was
39 | derived experimentally running the program against large directory scans on a Macbook SSD drive.
40 |
41 | - Run the Scanner on a directory tree with 100s of files, big enough that it takes approx 10secs to complete.
42 | Try varying the batch size parameter, by doubling or halving it progressively.
43 | Take multiple timings at each batch size. Is 32 the optimal value on your hardware or something else?
44 |
45 | - Also, if you have a multicore machine, observe the CPU usage as you change the batch size. You may see it rise with
46 | a smaller batch size, even if overall performance worsens. This is because it's doing more work in parallel but wasting
47 | effort on switching work between threads.
48 |
--------------------------------------------------------------------------------
/exerciseConcurrent/src/main/scala/scan/Scanner.scala:
--------------------------------------------------------------------------------
1 | package scan
2 |
3 | import java.nio.file._
4 |
5 | import scala.compat.java8.StreamConverters._
6 | import scala.collection.SortedSet
7 |
8 | import cats._
9 | import cats.data._
10 | import cats.implicits._
11 |
12 | import mouse.all._
13 |
14 | import org.atnos.eff._
15 | import org.atnos.eff.all._
16 | import org.atnos.eff.syntax.all._
17 |
18 | import org.atnos.eff.addon.monix._
19 | import org.atnos.eff.addon.monix.task._
20 | import org.atnos.eff.syntax.addon.monix.task._
21 |
22 | import monix.eval._
23 | import monix.execution._
24 |
25 | import EffTypes._
26 |
27 | import scala.concurrent.duration._
28 |
29 |
30 | object Scanner {
31 | val Usage = "Scanner