├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── copyright │ └── profiles_settings.xml ├── encodings.xml ├── gradle.xml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── jarRepositories.xml ├── kotlinc.xml ├── misc.xml ├── runConfigurations │ └── Run_all_the_tests.xml └── vcs.xml ├── .java-version ├── INSTRUCTIONS.part4.md ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── intro.md ├── sample-solution └── learnyouakotlin │ └── solution │ ├── part1 │ ├── Presenter.kt │ ├── Session.kt │ └── SessionTests.kt │ ├── part2 │ └── NullsTests.kt │ └── part3 │ ├── Json.kt │ ├── JsonFormat.kt │ ├── JsonFormatTests.kt │ ├── JsonFormatTests.session_to_json.approved │ ├── JsonFormatTests.session_with_subtitle_to_json.approved │ └── Result.kt ├── scripts └── md-to-pdf ├── settings.gradle ├── src ├── main │ └── java │ │ └── learnyouakotlin │ │ ├── part1 │ │ ├── Presenter.java │ │ ├── Session.java │ │ └── Slots.java │ │ ├── part2 │ │ └── Sessions.java │ │ ├── part3 │ │ ├── Json.java │ │ └── JsonFormat.java │ │ └── part4 │ │ ├── AttendeeId.java │ │ ├── Identifier.java │ │ ├── SessionId.java │ │ ├── SignupBook.java │ │ ├── SignupHttpHandler.java │ │ ├── SignupSheet.java │ │ └── Transactor.java └── test │ └── java │ └── learnyouakotlin │ ├── part1 │ └── SessionTests.java │ ├── part2 │ └── SessionsTests.kt.later │ ├── part3 │ ├── JsonFormatTests.java │ ├── JsonFormatTests.session_to_json.approved │ └── JsonFormatTests.session_with_subtitle_to_json.approved │ └── part4 │ ├── InMemoryHttpExchange.java │ ├── InMemorySignupBook.java │ ├── InMemoryTransactor.java │ ├── SessionSignupHttpTests.java │ └── SignupServer.java └── website └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | hs_err_pid* 2 | .gradle/ 3 | .idea/emacs.xml 4 | .idea/libraries/ 5 | .idea/modules/ 6 | .idea/workspace.xml 7 | .DS_Store 8 | build/ 9 | out/ 10 | tmp/ 11 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | uiDesigner.xml -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_all_the_tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 17 2 | -------------------------------------------------------------------------------- /INSTRUCTIONS.part4.md: -------------------------------------------------------------------------------- 1 | # Part 4: from mutable beans to unrepresentable illegal states 2 | 3 | ## Before the event 4 | 5 | Adding Kotlin to the project requires internet access. If the venue has patchy internet, it's best to do this before going to the venue, in a branch from the latest master, and start with "one we prepared earlier". 6 | 7 | ## Before people arrive 8 | 9 | On flip chart sheets: 10 | 11 | * Draw the logical diagram of the scenario 12 | * Draw the state machine and class hierarchy diagrams. 13 | 14 | Conceal them for use later. 15 | 16 | ## Before we start 17 | 18 | Ask audience about experience level with Kotlin and Java. 19 | 20 | We will live-code the transformation of Java to idiomatic Kotlin. But this demonstration is intended to be a starting-point for conversations about the topic. So ask questions as we go. The digressions *are* the tutorial. 21 | 22 | We are expecting you to already know Kotlin. However, if there are any language features you don't recognise, shout and we'll explain them. 23 | 24 | ## Explain the domain 25 | 26 | The code we are working on implements sign-up for conference sessions. 27 | 28 | ```plantuml 29 | :Attendee: as a 30 | :Presenter: as p 31 | :Admin: as b 32 | 33 | component "Attendee's Phone" as aPhone { 34 | component [Conference App] as aApp 35 | } 36 | component "Presenter's Phone" as pPhone { 37 | component [Conference App] as pApp 38 | } 39 | component "Web Browser" as bBrowser 40 | component "Conference Web Service" as webService 41 | 42 | a -right-> aApp : "sign-up\ncancel sign-up" 43 | aApp -down-> webService : HTTP 44 | 45 | p -left-> pApp : "close signup\nlist attendees" 46 | pApp -down-> webService : HTTP 47 | 48 | b -right-> bBrowser : "add sessions\nlist attendees" 49 | bBrowser -right-> webService : HTTP 50 | 51 | ``` 52 | 53 | Admin user creates sign-up sheets for sessions in an admin app (not covered in this example). Sessions have limited capacity, set when the sign-up sheet is created. 54 | Attendees sign up for sessions via mobile conference app. Admins can also sign attendees up for sessions via the admin app. 55 | The session presenter starts the session via the mobile conference app. After that, the sign-up sheet cannot be changed. 56 | 57 | The code is simplified for the sake of brevity and clarity: 58 | 59 | * It doesn't cover some edge cases. The techniques we will show apply equally well to those too. 60 | * PRESENTER NOTE: the example doesn't handle the case of cancelling the signup of an attendee who did not previously sign up, which would leave a full session still full. If anyone notices, mention that we could either handle this case as an explicit error case, using something like Result4k, or by modifying the state machine to model the idempotency of the cancelSignUp operation. Either way, we'd use the same techniques we are demonstrating, and so we'll skip these cases unless we have time to implement it at the end of the session. 61 | * It doesn't include authentication, authorisation, monitoring, tracing, etc. to focus on the topic of the exercise. 62 | 63 | 64 | ## Review the Java code 65 | 66 | * show SignupSheet. Highlight... 67 | * Beaniness: zero arg constructor, getters and setters, mutable state 68 | * Queries that execute some business logic (e.g. isFull()) 69 | * Methods that execute business logic (e.g. signUp) 70 | * Throws exceptions when methods used in the wrong state 71 | * Defensive copying of mutable collection from getSignUps() accessor 72 | * Strongly-typed IDs (SessionId, AttendeeId) & Identifier base class -- avoid stringly typed design. 73 | 74 | * SignupSheets are collected into a SignupBook 75 | * Hexagonal architecture 76 | * Sheets are added to the signup book out-of-band by an admin app, which is not shown in this example. 77 | 78 | * SignupHttpHandler implements the HTTP API by which a front-end controls the SignupSheet. 79 | * Routes on request path and method 80 | * Supports attendee sign up and cancellation, starting the session and listing who is signed up. 81 | * Translates exceptions from the SignupSheet into HTTP error responses 82 | 83 | * SessionSignupHttpTests: behaviour is tested at the HTTP API, with fast, in-memory tests. 84 | * Briefly walk through `can_only_sign_up_to_capacity` 85 | * Show the InMemorySignupBook that emulates the semantics of the persistence layer 86 | 87 | * SignupServer: 88 | * Example of a real HTTP server storing signups in memory 89 | 90 | 91 | Run the tests to show they pass. 92 | 93 | Let's convert this to Kotlin. 94 | 95 | * Our strategy is to start by converting the domain model and work outwards towards the HTTP layer. 96 | 97 | 98 | ## Adding Kotlin to the project 99 | 100 | First we must add Kotlin to the project. 101 | 102 | * Use the menu item: Tools > Kotlin > Configure Kotlin in Project 103 | * Reload the Gradle file 104 | * Show changes to the Gradle file 105 | * Change the kotlin.jvmToolchain declaration to match the Java toolchain: 106 | 107 | ~~~ 108 | kotlin { 109 | jvmToolchain { 110 | languageVersion.set(java.toolchain.languageVersion) 111 | } 112 | } 113 | ~~~ 114 | * Run all the tests – they pass. We can now use Kotlin in our project! 115 | 116 | COMMIT! 117 | 118 | ASK: Was that easier than you expected? 119 | 120 | 121 | ## Converting the Bean to Kotlin 122 | 123 | Now let's convert the SignupSheet to Kotlin. 124 | 125 | * Search for the action "Convert Java file to Kotlin" (hit Shift three times) 126 | * Copy the key combination onto the whiteboard: we'll be using it again! 127 | * Run the action. It pops up a dialog asking 128 | > Some code in the rest of your project may require corrections after performing this conversion. Do you want to find such code and correct it too? 129 | 130 | Answer "No". The dialog is misleading. Clicking "No" does not actually leave code broken, and clicking "Yes" makes changes to the Kotlin and Java we don't want. 131 | * This is not always the case, and the effect of clicking "Yes" changes with every new version of the Kotlin plugin. It's always worth trying both options and using the Git diff to see how change has affected your Java code. However, in large Java code bases, I find it's usually best to click "No" and then add annotations to the Kotlin source to make the Kotlin compiler generate Java bytecode compatible with existing Java. This ensures the remaining Java code remains in conventional Java style while the new Kotlin code follows conventional Kotlin style. 132 | * I won't lie... the converter has produced quite a dog's dinner 133 | * That's largely because the style of the Java does not match elegant Kotlin style. For the rest of this session, we'll apply Kotlin language features to clean up this code, and then improve it over what is possible in Java 134 | * But first... let's run the tests. 135 | * They still pass. 136 | * What does that show us? Our Java is seamlessly calling into our Kotlin, and our Kotlin seamlessly calling into our Java. We haven't had to write any interop layer between the Kotlin and the Java. 137 | 138 | COMMIT! 139 | 140 | Let's tidy up the SignupSheet class before we convert the SignupHttpHandler. Once we have both classes in Kotlin, we can really start using language features to eliminate some of the nastiness in the SignupSheet class. But for now, we can at least make the SignupSheet code more concise and expressive, even though it will be mere "Java in Kotlin syntax". 141 | 142 | Describe properties ... they are compiled to Java getter/setter methods. They can have a backing field (e.g. sessionId) or be entirely virtual (e.g. isFull), or you can define the get/set actions to mediate access to the backing field, using the (relatively new) `field` keyword. 143 | 144 | The Java to Kotlin converter hasn't quite kept up with the latest language changes, so we will have to tweak the code to make best use of them... 145 | 146 | Move the get/set methods up so that they are next to the private var for the relevant property. 147 | 148 | The get/set methods implement something that the Kotlin language has specific syntax for: property setter logic that assigns to the backing field. 149 | 150 | Make var sessionId public and declare a setter that executes the check. Do the same for capacity. E.g. they should look like: 151 | 152 | ~~~ 153 | var sessionId: SessionId? = null 154 | set(value) { 155 | check(sessionId == null) { 156 | "you cannot change the sessionId after it has been set" 157 | } 158 | field = value 159 | } 160 | 161 | var capacity = 0 162 | set(value) { 163 | check(capacity == 0) { 164 | "you cannot change the capacity after it has been set" 165 | } 166 | field = value 167 | } 168 | ~~~ 169 | 170 | Delete the getter and setter 171 | 172 | Run the tests. They pass. COMMIT! 173 | 174 | Command-click on the `capacity` property to show usages. The usages include a call to the setter from the Java SignupServer class. NOTE: Our change has had no effect on Java code or Kotlin code that uses the properties. 175 | 176 | We do not have uses of `capacity` from Kotlin yet, because apart from the setter call in Java, the capacity is only set by the constructor. We'll address that presently... 177 | 178 | Now let's look at `signups`. Move the `getSignups()` method up next to the `signups` property. 179 | 180 | * We have a private mutable set that is exposed as Set by a public accessor. 181 | * Command-hover over the Set return type declaration. 182 | * It shows the type is `kotlin.Set`, not `java.util.Set`. 183 | * In Kotlin, collections are non-mutable by default 184 | * The getter is making a defensive copy to avoid aliasing bugs. 185 | * Kotlin has convenience functions for that: Replace `Set.copyOf(signups)` with `signups.toSet()` 186 | * Mention the naming convention of `toSet` vs `asSet` 187 | 188 | Run the tests. They pass. COMMIT! 189 | 190 | We can use Kotlin syntactic sugar for the set operations: 191 | 192 | * Change the declaration of the set from `LinkedHasSet()` to `mutableSetOf()`. It's the same thing. 193 | * In isSignedUp, use Option-Enter to replace `contains` with the `in` operator. 194 | * EXPLAIN: Kotlin operators are syntactic sugar for method calls, and the compiler applies the desugaring when Kotlin calls Java classes too. 195 | * In `signUp` use Option-Enter to replace `add` with the `+=` operator. 196 | * In cancelSignUp, we can replace `remove` with the `-=` operator, but have to do it by hand. It's not on the Option-Enter menu for some reason! 197 | 198 | Run all the tests. They pass. COMMIT! 199 | 200 | Let's look at the signups `property` again. The code has the private property as a mutable Set, but exposes it publicly as an immutable Set. We cannot declare the get and set of a property to have different types (at least, at the time of writing). However, Kotlin provides lots of useful functions for manipulating non-modifiable collections in a functional way. So, instead, we can replace the immutable reference to a mutable Set with a _mutable_ reference to an immutable Set, and make the setter private (using the same syntax as the code converter generated for isClosed): 201 | 202 | * Change the `val signups` to a `var` and initialise it to `emptySet()`. 203 | * Run the tests to make sure that's not broken anything. 204 | * NOTE: That's all we need to do! The `+=` and `-=` syntax also desugars to application `+` and `-` to _immutable_ values and assignment to mutable variables. 205 | * Finally, make `var signups` public, with a `private set`, and delete the setter. 206 | 207 | Run the tests. They pass. COMMIT! 208 | 209 | 210 | ## Review the Kotlin code 211 | 212 | The class is now much cleaner... it's an idiomatic Kotlin implementation of Java style "bean" code. 213 | 214 | Review other aspects of the code... 215 | 216 | * constructor keyword 217 | * check library functions instead of if statements throwing exceptions 218 | 219 | ASK: What other differences stand out to the audience? 220 | 221 | We can now see the wood for the trees... 222 | 223 | * Inappropriate mutation (e.g. sessionId, capacity) 224 | * Throws exceptions if client code uses the object incorrectly. 225 | 226 | Wouldn't it be better if the client code could NOT use the object incorrectly? 227 | 228 | We can make that happen! Getting rid of the mutation is the first step on the way, so let's do that first. 229 | 230 | The SignupSheet is used in the SignupHttpHandler, so if we will make the SignupSheet immutable, we'll need to change the handler to work with immutable values, rather than mutable beans. We might as well convert that to Kotlin first... 231 | 232 | ## Converting the HTTP handler to Kotlin 233 | 234 | Convert SignupHttpHandler to Kotlin ... Click "Yes" in the dialog. 235 | 236 | Run the tests. They pass. COMMIT! 237 | 238 | 239 | Review the code of SignupHttpHandler. The converter has done a pretty good job. 240 | 241 | * Explain @JvmField -- show uses of the route constants in the SignupHttpTest to explain how the annotation prevents field references being replaced by calls to getter methods. Especially useful when you care about the way the call-site looks, such as when you have an "embedded" DSL. 242 | * Explain @Throws ... we don't need them because the exception is declared by the HttpHandler interface, so delete them. 243 | 244 | Run the tests. They pass. COMMIT! 245 | 246 | The Kotlin code of SignupHttpHandler is still very similar to Java code. We are not going to change this class very much -- it's "shape" is dictated by the HTTP server library we are using. However, now that it is in Kotlin we can take advantage of more Kotlin features in the SessionSignup class. So we will tidy this code up a little, and then get back to SignupSheet... 247 | 248 | Use the beige highlights in the right-hand gutter to review the warnings: the IDE is telling us that we should use Kotlin's collection types and functions from the Kotlin standard library. Let's apply its suggestions using Option-Enter, starting by replacing `List.of` with `listOf`. Now the class doesn't use Java's List type, and we remove the unused import with "optimise imports" (Control-Option-O). 249 | 250 | Run the tests. They pass. COMMIT! 251 | 252 | In `handle`, replace `if (xxx == null) { ... }` statements with the elvis operator and `run` scope function. E.g. 253 | 254 | ~~~ 255 | doSomething() 256 | ?: run { 257 | sendResponse(NOT_FOUND, ... ) 258 | } 259 | ~~~ 260 | 261 | Now to replace use of Java streams with Kotlin's stdlib functions... 262 | 263 | In handleSignups, replace the use of streams with Kotlin's map and joinToString extensions. 264 | 265 | Run the tests. They pass. COMMIT! 266 | 267 | 268 | Point out the grey underline on `map` and show the audience the suggestion. 269 | 270 | * Apply the suggestion with Option-Enter. 271 | * The style suggestions are a great way to learn the standard library. Especially useful when you take on a new Kotlin release with new functions in the stdlib. 272 | * Option-Enter on `obj` in the lambda, and remove explicit lambda parameter types. Note the warning "may break code". Run the tests to confirm that it hasn't. 273 | * Option-Enter on `obj` in the lambda and replace named parameter with `it`, and run the tests. 274 | * Use Option-Enter to move the lambda into the parameter list. Is that more readable? I think so. Let's keep it. 275 | 276 | Run the tests. They pass. COMMIT! 277 | 278 | 279 | In `matchRoute`, Option-Enter on the `for` keyword and `Replace with firstOrNull`. Pretty impressive! 280 | 281 | The `matchRoute` function is only used in one place. Now it's a one-liner, it's not really pulling its weight. 282 | * Inline at the call-site. 283 | 284 | Run the tests . They pass. COMMIT! 285 | 286 | Replace the === operators with == in the if statement. Run the tests. 287 | 288 | Now the `if` is highlighted. Option-Enter and replace `if` with `when`. 289 | 290 | * EXPLAIN: Java's switch statement can only branch on primitive and string types. Kotlin's when can switch on anything. 291 | Option-Enter on the `when` and remove braces from all entries. 292 | 293 | Run the tests. They pass. COMMIT! 294 | 295 | 296 | The HTTP handler is good enough for now... let's return to SignupSheet. 297 | 298 | 299 | ## Converting the bean to an immutable data class 300 | 301 | Recall our plan... we will make SignupSheet immutable, and then we will use the type system to make it impossible for client code to call methods when the object is in an inappropriate state. 302 | 303 | Remember the refactoring we did for the signups set, in which we replaced an immutable reference to a mutable collection with a mutable reference to an immutable collection? We'll apply the same strategy to how SignupHttpHandler uses SignupSheet. 304 | 305 | However, we have a chicken-and-egg situation... SignupSheet needs functional operations before we can use the strategy, and we need to have applied the strategy to make SignupSheet functional. The change feels too big to do in one go. 306 | 307 | We need _another_ strategy to break the refactoring into small, safe steps, and that is: 308 | 309 | 1. Change the SignupSheet so that its API looks functional but also mutates the object -- a so-called "fluent" or "chained" API style. 310 | 2. Change clients to use the chained API so that they treat the SignupSheet as if it were immutable 311 | 3. Make the SignupSheet immutable 312 | 313 | Step 1: make the mutator methods return `this` 314 | 315 | * Add `return this` at the end of `close`, `signUp` and `cancelSignUp`, and Option-Enter to add the return type to the method signature 316 | 317 | Run the tests. They pass. COMMIT! 318 | 319 | 320 | Step 2: in SignupHttpHandler, replace sequential statements that mutate and then save with a single statement passes the result of the mutator to the `save` method, like: `book.save(sheet.close())`. We can do this quickly by: 321 | 322 | * Assign the result of the mutator call to a val called sheet. E.g. `val sheet = sheet.close()` 323 | * The new sheet val is now highlighted as a warning because it shadows the same name in the outer scope. 324 | * Inline the highlighted `sheet` into the call to `book.save(sheet)`, leaving the mutator calls like: `book.save(sheet.close())`. 325 | 326 | Run the tests. They pass. COMMIT! 327 | 328 | 329 | In SignupServer, replace the mutation of the sheet with a call to the constructor and inline the `sheet` variable. 330 | 331 | We don't have a test for the server -- it is test code -- but COMMIT! anyway. 332 | 333 | We can now delete the no-arg constructor. 334 | 335 | * ASIDE: Like most Java code, this example uses Java Bean naming conventions but not actual Java Beans. 336 | * In SignupSheet the no-arg constructor is now unused. Safe-delete it with Option-Enter. 337 | 338 | Run the tests. They pass. COMMIT! 339 | 340 | Convert the constructor to a primary constructor by clicking on the declaration and Option-Enter. 341 | 342 | Run the tests. They pass. COMMIT! 343 | 344 | Make sessionId a non-nullable val declared in primary constructor. 345 | 346 | Make capacity a val declared in primary constructor. 347 | 348 | * delete the entire var property including the checks. Those are now enforced by the type system. 349 | 350 | Now to transform the mutator methods into transformations... 351 | 352 | * Declare `signups` as a val in the primary constructor, initialised to `emptySet()` 353 | * Declare `isClosed` as a val in the primary constructor, initialised as `false` 354 | * Try running the tests... The mutators do not compile. Change them so that, instead of mutating a property, they return a new copy of the object that one property changed. 355 | * Try running the tests... we've broken Java code. Java doesn't support default parameters. But we can make the Kotlin compiler generate overloaded constructors for us by adding the @JvmOverloads annotation to the primary constructor: 356 | 357 | ~~~ 358 | class SignupSheet @JvmOverloads constructor( 359 | val sessionId: SessionId, 360 | val capacity: Int, 361 | ... 362 | ~~~ 363 | 364 | Run the tests... they fail! We also have to update our in-memory simulation of persistence, the InMemorySignupBook. 365 | 366 | * all the code to return a copy of the stored SignupSheet is now unnecessary because SignupSheet is immutable. 367 | * Delete it all, and return the value obtained from the map 368 | 369 | Run the tests. They pass. COMMIT! 370 | 371 | We can turn most methods into expression form. 372 | 373 | * We cannot do this for signUp because of those checks. We'll come back to those shortly... 374 | 375 | ASIDE: I prefer to use block form for functions with side effects and expression for pure functions. 376 | 377 | We can remove duplication by making the code a data class and using the copy method. 378 | 379 | * Declare the class as a data class 380 | * Replace all calls to constructor with calls to copy, and remove unnecessary parameters 381 | 382 | Run the tests. They pass. COMMIT! 383 | 384 | The data class does allow us to make the state of a signup sheet inconsistent, by passing in more signups than the capacity. 385 | 386 | * Add a check in the init block: 387 | 388 | ~~~ 389 | init { 390 | check(signups.size <= capacity) { 391 | "session full" 392 | } 393 | } 394 | ~~~ 395 | * This makes the isFull check in signUp redundant, so delete it. 396 | 397 | 398 | ## Making illegal states unrepresentable 399 | 400 | Now... those checks... it would be better to prevent client code from using the SignupSheet incorrectly than to throw an exception after they have used it incorrectly. In FP circles this is sometimes referred to as "making illegal states unrepresentable". 401 | 402 | The SignupSheet class implements a state machine: 403 | 404 | IF TIME: we need better names... ask the audience for suggestions. 405 | 406 | ~~~plantuml 407 | 408 | state Open { 409 | state choice <> 410 | state closed <> 411 | state open <> 412 | 413 | open -down-> Available 414 | Available -down-> Available : cancelSignUp(a) 415 | Available -right-> choice : signUp(a) 416 | choice -right-> Full : [#signups = capacity] 417 | choice -up-> Available : [#signups < capacity] 418 | Full -left-> Available : cancelSignUp(a) 419 | 420 | Available -> closed : close() 421 | Full -> closed : close() 422 | } 423 | 424 | [*] -down-> open 425 | closed -> Closed 426 | ~~~ 427 | 428 | REVEAL: the state diagram drawn on the flip-chart... 429 | 430 | * The _signUp_ operation only makes sense in the Available sub-state of Open. 431 | 432 | * The _cancelSignUp_ operation only makes sense in the Open state. 433 | 434 | * The _close_ operation only makes sense in the Open state. 435 | 436 | We can express this in Kotlin with a _sealed type hierarchy_... 437 | 438 | REVEAL: the type hierarchy drawn on the flip-chart. 439 | 440 | ~~~plantuml 441 | hide empty members 442 | hide circle 443 | 444 | class SignupSheet <> 445 | class Open <> extends SignupSheet { 446 | close(): Closed 447 | cancelSignUp(a): Available 448 | } 449 | 450 | class Available extends Open { 451 | signUp(a): Open 452 | } 453 | 454 | class Full extends Open 455 | 456 | class Closed extends SignupSheet 457 | ~~~ 458 | 459 | 460 | We'll introduce this state by state, starting with Open vs Closed, replacing predicates of the properties of the class with subtype relationships. 461 | 462 | Unfortunately IntelliJ doesn't have any automated refactorings to split a class into a sealed hierarchy, so we'll have to do it the old-fashioned way... by hand ... like C++ programmers... 463 | 464 | ### Open/Closed states 465 | 466 | * Extract an abstract base class from SignupSheet 467 | * NOTE: IntelliJ seems to have lost the ability to rename a class and extract an interface with the original name. So, we'll have to extract the base class with a temporary name and then rename class and interface to what we want. 468 | * call it anything, we're about to rename it. SignupSheetBase, for example. 469 | * Pull up sessionId, capacity & signups as abstract members and isSignedUp as a concrete member. 470 | * This refactoring doesn't work 100% for Kotlin, so fix the errors in the interface by hand. 471 | 472 | * Change the name of the subclass by hand (not a rename refactor) to Open, and then use a rename refactoring to rename the base class to SignupSheet. 473 | * Repeatedly run all the tests to locate all the compilation errors... 474 | * In SignupHttpHandler, there are calls to methods of the Open class that are not defined on the SignupSheet class. 475 | * wrap the try/catch blocks in `when(sheet) { is Open -> try { ... } }` to get things compiling again. E.g. 476 | 477 | ~~~ 478 | when (sheet) { 479 | is Open -> 480 | try { 481 | book.save(sheet.signUp(attendeeId)) 482 | sendResponse(exchange, OK, "subscribed") 483 | } catch (e: IllegalStateException) { 484 | sendResponse(exchange, CONFLICT, e.message) 485 | } 486 | } 487 | } 488 | ~~~ 489 | 490 | * In SessionSignupHttpTests and SignupServer we need to create Open instead of SessionSignup. 491 | * If we convert all call sites to Kotlin first, there are tricks we can use to do this safely without manual edits. IntelliJ doesn't yet have a "Replace constructor with factory method" refactoring for Kotlin classes. However, there are so few places that create the new Availability objects it is not worth introducing a factory method. We'll fix it up by hand... 492 | * Fix it up by hand. 493 | * Easiest way is to select "new SignupSheet", then Command-R to replace all instances with "new Open" 494 | 495 | * Run the tests. They should all pass. 496 | * Change the base class from "abstract" to "sealed". 497 | 498 | Run the tests. They pass. COMMIT! 499 | 500 | Now we can add the Closed subclass: 501 | 502 | * NOTE: do not use the "Implement sealed class" action... it does not give the option to create the class in the same file. Instead... 503 | * Define a new `data class Closed : SignupSheet()` in the same file 504 | * The new class is highlighted with an error underline. Option-Enter on the highlighted error, choose "Implement as constructor parameters", ensure sessionId, capacity, and signups are selected in the pop-up (default behaviour), and perform the action. 505 | * Option-Enter on the highlighted error again, choose "Implement members", select all the remaining members 506 | 507 | We've broken our HTTP handler, so before we use the Closed class to implement our state machine, let's get it compiling again. 508 | 509 | * Add when clauses for Closed that just call TODO(), by Option-Enter-ing on the errors and selecting "Add remaining branches" 510 | 511 | Run the tests to verify that we have not broken anything... we are not actually using the Closed class yet. 512 | 513 | Now make Open.close() return an instance of Closed: 514 | 515 | ~~~ 516 | fun close() = 517 | Closed(sessionId, capacity, signups) 518 | ~~~ 519 | 520 | Run the tests: there are failures because of the TODO() calls: 521 | 522 | * in handleSignup, replace TODO calls by sending a CONFLICT status with an error message (e.g. "sign-up closed") as the body text. 523 | * in handleClose: 524 | * GET: replace with returning `sheet is Closed` 525 | * POST: there is nothing to do if the session is already closed, replace the TODO() with an empty branch and a comment like "// nothing to do" and move the call to sendResponse after the `when` block. 526 | 527 | Run the tests. They pass. COMMIT! 528 | 529 | Look for uses of isClosed. The only calls are accessors in the checks. Therefore, the value never changes, and is always false. The checks are dead code, because we have replaced the use of the boolean property with subtyping. 530 | 531 | * Delete the check statements 532 | * Safe-Delete the isClosed constructor parameter 533 | 534 | Run the tests. They pass. COMMIT! 535 | 536 | Review the class... now we have methods that return the abstract SessionSignup type. We can make the code express the state transitions explicitly in the type system be declaring the methods to return the concrete type (or letting Kotlin infer the result type). 537 | 538 | * ASIDE: I prefer to explicitly declare the result type I want. 539 | * Declare the result of close() as Closed, and of signUp & cancelSignUp as Open 540 | 541 | Run the tests. They pass. COMMIT! 542 | 543 | ### Available/Full states 544 | 545 | We still have the try/catch blocks because the SignupSheet throws IllegalStateException if you call sign up when the session is full. We can represent that with types in the same way... 546 | 547 | Rename Open to Available 548 | 549 | Run all the tests. They should still pass. 550 | 551 | Extract an abstract superclass Open, pulling up close and cancelSignUp as concrete. (Ignore the members highlighted in red in the dialog -- they will be inherited from the SignupSheet base class). 552 | 553 | Make Open a sealed class. This will get rid of any compilation errors. 554 | 555 | Run all the tests. They should still pass. 556 | 557 | Add a new subclass, Full, derived from Open, like this: 558 | 559 | ~~~ 560 | data class Full : Open() 561 | ~~~ 562 | 563 | * It will be underlined with a red error highlight. 564 | * Option-Enter on the error, select "Implement as constructor parameters", and select sessionId and signups in the dialog 565 | * The class will still be underlined with a red error highlight because `capacity` has not been implemented yet 566 | * Option-Enter on the error, select "Implement members", and select Ok 567 | * Implement `capacity` to evaluate to `signups.size` 568 | * The end result should therefore be: 569 | 570 | ~~~ 571 | data class Full( 572 | override val sessionId: SessionId, 573 | override val signups: Set 574 | ) : Open() { 575 | override val capacity: Int 576 | get() = signups.size 577 | } 578 | ~~~ 579 | 580 | Run all the tests. Now SignupHttpHandler won't compile because the Full case is not handled. 581 | 582 | Make all the `when` expressions exhaustive: 583 | 584 | * in handleSignup for POST, Option-Enter on the `when` and choose "Add remaining branches" 585 | * in handleSignup for DELETE and handleClose, change when condition from `is Available` to `is Open` 586 | 587 | 588 | Change Available::signUp to return Available or Full, depending on whether the number of signups reaches capacity: 589 | 590 | * extract `signups + attendeeId` as a variable, newSignups 591 | * Change result to return Full when newSignups.size == capacity: 592 | 593 | ~~~ 594 | return when (newSignups.size) { 595 | capacity -> Full(sessionId, newSignups) 596 | else -> copy(signups = newSignups) 597 | } 598 | ~~~ 599 | 600 | Run the tests. They fail. 601 | 602 | Make them pass by: 603 | 604 | * Implementing the `is Full` condition as: 605 | 606 | ~~~ 607 | is Full -> { 608 | sendResponse(exchange, CONFLICT, "session full") 609 | } 610 | ~~~ 611 | 612 | Run the tests. They pass. COMMIT! 613 | 614 | Review the subclasses of SignupSheet. The classes no longer check that methods are called in the right state. The only remaining check, in the init block, defines a class invariant that the internal implementation maintains. We can remove the try/catch in our HTTP handler! 615 | 616 | * Unwrap the try/catch blocks in the SignupHttpHandler (add braces to when clause with Option-Enter if necessary) 617 | 618 | 619 | ## If time: Converting the methods to extensions 620 | 621 | If we have time, convert methods to extensions (Option-Enter on the methods). 622 | 623 | Change the result types to the most specific possible. 624 | 625 | Gather the types and functions into two separate groups. 626 | 627 | Fold away the function bodies. Ta-da! The function signatures describe the state machine! 628 | 629 | 630 | ## If time: Converting identifiers to value classes 631 | 632 | Convert Java to Kotlin, remove the inheritance and edit to be a value class. Then inline the `of` method. It's not required. 633 | 634 | 635 | ## Wrap up 636 | 637 | Review the code of SignupSheet and SignupHttpHandler 638 | 639 | What have we done? 640 | 641 | * Converted Java code to Kotlin _incrementally_, ensuring the project is always working and gradually making use of more Kotlin features as Kotlin spreads through our codebase. 642 | * Used IntelliJ's automatic refactorings, corrections and code intentions wherever possible -- we seldom needed to edit the code _as text_. 643 | * Refactored a mutable, object-oriented domain model to an immutable, algebraic data type and operations on the data type. 644 | * Pushed mutation outward, to the edge of our system 645 | * Replaced runtime tests throwing exceptions for invalid method calls, with type safety: it is impossible to call methods in the wrong state because those operations do not exist in those states 646 | * Pushed error checking outwards, to the edge of the system, where the system has the most context to handle the error 647 | * IF TIME: Fixed a subtle bug in our original code... did anyone spot it? 648 | * Show original version 649 | * Answer: both HttpExchange and SignupSheet can throw IllegalStateException, and the error handling does not distinguish between the two situations. In our final version, we clearly distinguish between expected states in our domain and programming errors. 650 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Exercise for the tutorial "Refactoring to Kotlin" 2 | ================================================= 3 | 4 | Aim of the session is to introduce Kotlin by converting Java code. 5 | 6 | First create a new branch. Check in after each change. This lets you 7 | easily show how auto-converting code to Kotlin affects how its API 8 | looks when used from Java 9 | 10 | Suggested progress 11 | 12 | * Part 0: Compiling Kotlin 13 | * Open project in IntelliJ, build on command line, run tests in IntelliJ 14 | * Tools/Kotlin/Configure Kotlin in Project 15 | * Look at the changes to build.gradle 16 | * Build and test 17 | * Tools/Kotlin/Configure Kotlin Plugin Updates - use New Java To Kotlin Converter 18 | 19 | * Part 1: Class syntax and data classes 20 | * Presenter 21 | * note that this an immutable value class with a public final field 22 | * convert to Kotlin, accept change other files. 23 | * try to run tests - see compilation failure, talk about converter 24 | * talk about properties v fields, fix JsonFormat.java (Alt-Enter) 25 | * talk through the bits of the Kotlin class - ctor, property, methods 26 | * run tests 27 | * remove equals, hashcode, to string - show tests fail 28 | * make a data class - show tests now pass 29 | * remove the unneeded class body 30 | * check in 31 | * Session 32 | * note that this is an immutable value class with public fields, one of which is nullable, and it defensively copies the presenters 33 | * also that we have 2 constructors - one a convenience vararg 34 | * convert to Kotlin 35 | * Note subtitle is a `String?` - talk about nullability 36 | * Note primary v secondary constructor, observe primary ctor invocation 37 | * Note we can have a free property - presenters, initialised in class body 38 | * Talk about init block, but then remove it 39 | * show tests pass, remove equals etc - show tests fail 40 | * convert to a data class 41 | * can't have a val outside ctor, remove it 42 | * No need to wrap List in unmodifiableList: discuss List/MutableList split, show List defn 43 | * Observe spread operator in constructor, remove it and replace with presenters.toList() - discuss asList() 44 | * Remove empty ctor body 45 | * Convert withXxx methods to single expression - note lack of new 46 | * Convert withXxx methods to invoke .copy (do via add argument names and talk about argument names) 47 | * run the tests, check diffs, talk about diffs, checkin 48 | * SessionTests 49 | * convert to Kotlin 50 | * run tests 51 | * talk about "internal" 52 | * talk about "var" vs "val" 53 | * talk about lack of a "new" keyword -- classes look like, and can be used as, functions 54 | * talk about listOf vs Arrays.asList -- Kotlin stdlib has lots of useful collection methods 55 | * talk about lack of return type when Unit 56 | * Now all of our Session clients are Kotlin we can inline the 'copy's, except for 57 | withPresenters, which we can make vararg 58 | * Now the copy invocations don't need testing 59 | * Move withPresenters methods out of class into extension ... much nicer in Kotlin, yeah? 60 | * Explain extension functions in more detail ... syntactic sugar for static methods 61 | * Move withPresenters into SessionTests where it is used to illustrate convenience extensions 62 | * Talk about top-level functions 63 | * Rename test to `illustrate convenience extension methods` and talk about names 64 | * run the tests, check diffs, talk about diffs, checkin 65 | * Slots 66 | * Convert Slots. It's all Kotlin!!! That was easy! 67 | * run the tests, check diffs, talk about diffs, checkin 68 | 69 | * Part 2: Null and nullability 70 | * Look at Sessions - a bunch of static convenience methods to manage a collection 71 | * Look at SessionsTests - already Kotlin 72 | * Talk about companion object, static etc 73 | * `nulls` test 74 | * show TypeCastException when we change the title 75 | * change cast to !! and show KotlinNullPointerException when we change the title 76 | * Show infers second reference cannot be null because of flow typing 77 | * Show the type of notNullSession given as or !! 78 | * Convert Sessions to Kotlin 79 | * Run tests 80 | * subtitleOf 81 | * Compare with Java - talk about ?. 82 | * subtitleOrPrompt 83 | * Compare with Java - talk about ?: 84 | * Move Session static methods to top level scope - talk about static scope 85 | * Make into extension functions 86 | * Note use of extension functions on nullable types 87 | * Remove boilerplate 88 | * Convert subtitleOrPrompt to property (Alt-Enter) 89 | * Talk about properties v functions 90 | * Convert findWithTitle to Kotlin (remove .stream()) - note lambda syntax and destructuring 91 | * Remove the destructuring as unhelpful (even risky -- explain risks) 92 | * Talk about the difference between iterables and sequences 93 | * Use predicate form of firstOfNull 94 | * Run tests 95 | * Talk about API design by adding extension methods to existing types instead of defining new types 96 | * typealias List to Sessions 97 | * run the tests, check diffs, talk about diffs, checkin 98 | 99 | * Part 3: modules and functions 100 | * Look at JsonFormatTests 101 | * note that we want to marshall session to and from JSON 102 | * Look at JsonFormat 103 | * we're groping towards a Java DSL for JSON, using Json 104 | * Look at Json 105 | * Try annotating `props` param of `obj` method with `@Nullable` so comments about nullability 106 | are not necessary -- you cannot! 107 | * Note use of Map.Entry - used as a pair. But Kotlin has a pair. 108 | * Import Map.Entry, replace Entry< with Pair<, fix issues, 109 | * Run tests, checkin 110 | * Convert to Kotlin, applying changes to affected code 111 | * You'll get compiler errors - ignore them for now 112 | * Look at the changes. JsonFormat and JsonFormatTests are full of INSTANCE! 113 | * Explain Kotlin objects -- they are singletons!!! :scream-emoji: 114 | * Revert. 115 | * We could annotate all methods in Json with @JvmStatic. Or we could convert the dependent classes first. Let's do the latter. 116 | * JsonFormatTests 117 | * Convert to Kotlin AND RERUN THE TESTS 118 | * They fail, because JUnit needs `approval` to be a field. Annotate with @JvmField 119 | * Also mention the @Throws annotations, and then remove them 120 | * Run and checkin 121 | * JsonFormat 122 | * Convert to Kotlin - IJ doesn't do a very good job in the face of Java lambdas sometimes 123 | * Try the pedagogical object: java.util.function.Function fix 124 | * Fix compilation errors by removing explicit `Function<...>` SAM notation 125 | * Explain `it` variable in lambdas 126 | * Don't convert lambdas to references - do move them outside parameter list 127 | * That collect turns out to be `collect, Any>(Collectors.toList() as Collector>?` 128 | * Run tests 129 | * Remove @Throws: it's not called from Java any more (we'll talk about type safe error handling later if we have time) 130 | * Convert streams code to Kotlin map/flatMap/etc. (Remember that JsonNode is iterable, so has map, etc. defined for it) 131 | * move functions to module scope 132 | * convert to extension methods on domain types and JsonNode 133 | * Test and checkin 134 | * Back to Json 135 | * Convert to Kotlin 136 | * To make it compile: 137 | * Use Kotlin's function type syntax instead of java.util.Function 138 | * remove some explicit type params that are not needed 139 | * use nullable types to indicate that array and iterable *elements* can be null 140 | * move functions to module scope 141 | * remove streams 142 | * use infix to 143 | * observe `object` 144 | * replace props.forEach with filterNotNull().toMap() 145 | * in array use apply to initialise result ... 146 | * ... but then replace it with ArrayNode(nodes, elements.toList()) 147 | * Convert functions to extension methods where applicable 148 | * We can get rid of Iterable.array(fn) now 149 | * Convert `prop(name,value)` to `name of value` (infix function) 150 | * Discuss gradual introduction of mini-DSLs, rather than up-front DSL design which often ends up inflexible 151 | * Back to JsonFormat 152 | * convert the JSON as text into multiline strings 153 | * make extension properties from nonBlank functions 154 | * use isNullOrBlank 155 | * use let in Session.toJson 156 | 157 | Themes 158 | 159 | * pragmatic language 160 | * Java interop 161 | * tooling 162 | * much less classy than Java 163 | * extension functions for fun and profit 164 | 165 | There is a lot we still haven't covered 166 | 167 | * delegation 168 | * sealed classes 169 | * when expressions 170 | * sequences 171 | * inline functions 172 | * reified types in functions 173 | * coroutines 174 | * error handling 175 | * ... 176 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `java-library` 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | } 8 | 9 | dependencies { 10 | api("org.jetbrains:annotations:24.0.1") 11 | api("com.fasterxml.jackson.core:jackson-databind:2.15.0") 12 | implementation("org.glassfish.jersey.core:jersey-server:3.1.1") 13 | implementation("org.apache.httpcomponents.client5:httpclient5:5.2.1") 14 | 15 | testImplementation(platform("org.junit:junit-bom:5.9.3")) 16 | testImplementation("org.junit.jupiter:junit-jupiter") 17 | testImplementation("com.natpryce:hamkrest:1.8.0.1") 18 | testImplementation("com.oneeyedmen:okeydoke:1.3.3") 19 | } 20 | 21 | tasks.withType { 22 | useJUnitPlatform() 23 | } 24 | 25 | java { 26 | toolchain { 27 | languageVersion.set(JavaLanguageVersion.of(17)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.parallel=true 2 | org.gradle.caching=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/npryce/learn-you-a-kotlin/91035be02d5f8eca31a4376dc6e84aa51573e4c8/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /intro.md: -------------------------------------------------------------------------------- 1 | Refactoring to Kotlin 2 | ============================================================================== 3 | 4 | Duncan McGregor @duncanmcg 5 | 6 | Nat Pryce @natpryce 7 | -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part1/Presenter.kt: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.solution.part1 2 | 3 | data class Presenter(val name: String) { 4 | override fun toString() = name 5 | } -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part1/Session.kt: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.solution.part1 2 | 3 | typealias Slots = IntRange 4 | 5 | data class Session(val title: String, val subtitle: String? = null, val slots: Slots, val presenters: List) { 6 | constructor(title: String, subtitle: String? = null, slots: Slots, vararg presenters: Presenter) 7 | : this(title, subtitle, slots, listOf(*presenters)) 8 | } 9 | 10 | fun Session.withPresenters(newLineUp: List) = copy(presenters = newLineUp) 11 | fun Session.withTitle(newTitle: String) = copy(title = newTitle) 12 | fun Session.withSubtitle(newSubtitle: String?) = copy(subtitle = newSubtitle) 13 | 14 | -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part1/SessionTests.kt: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.solution.part1 2 | 3 | import org.hamcrest.CoreMatchers.equalTo 4 | import org.hamcrest.MatcherAssert.assertThat 5 | import org.junit.Test 6 | import java.util.Arrays.asList 7 | 8 | class SessionTests { 9 | val original = Session("The Title", null, 2..3, Presenter("Alice")) 10 | 11 | @Test 12 | fun can_change_presenters() { 13 | assertThat(original.withPresenters(asList(Presenter("Bob"), Presenter("Carol"))), equalTo( 14 | Session("The Title", null, 2..3, Presenter("Bob"), Presenter("Carol")))) 15 | } 16 | 17 | @Test 18 | fun can_change_title() { 19 | assertThat(original.withTitle("Another Title"), equalTo( 20 | Session("Another Title", null, 2..3, Presenter("Alice")))) 21 | } 22 | 23 | @Test 24 | fun can_change_subtitle() { 25 | assertThat(original.withSubtitle("The Subtitle"), equalTo( 26 | Session("The Title", "The Subtitle", 2..3, Presenter("Alice")))) 27 | } 28 | 29 | @Test 30 | fun can_remove_subtitle() { 31 | assertThat(original.withSubtitle("The Subtitle").withSubtitle(null), equalTo( 32 | Session("The Title", null, 2..3, Presenter("Alice")))) 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part2/NullsTests.kt: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.solution.part2 2 | 3 | import learnyouakotlin.solution.part1.Session 4 | import learnyouakotlin.solution.part1.Slots 5 | import org.junit.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertNull 8 | 9 | 10 | class NullsTests { 11 | 12 | @Test fun nulls() { 13 | val session: Session? = Sessions.sessionWithTitle("learn you a kotlin") 14 | assertEquals("for all the good it will do you", session!!.subtitle) 15 | } 16 | 17 | @Test fun elvis() { 18 | assertEquals("for all the good it will do you", subtitleOf(learnYouAKotlin)) 19 | assertNull(subtitleOf(null)) 20 | } 21 | 22 | @Test fun questionmark_thingy() { 23 | assertEquals("for all the good it will do you", learnYouAKotlin.subtitleOrPrompt()) 24 | assertEquals("click to enter subtitle", refactoringToStreams.subtitleOrPrompt()) 25 | assertEquals("click to enter subtitle", null.subtitleOrPrompt()) 26 | } 27 | 28 | } 29 | 30 | private val learnYouAKotlin = Session("Learn you a kotlin", "for all the good it will do you", Slots(1,1)) 31 | private val refactoringToStreams = Session("Refactoring to Streams", null, Slots(2,2)) 32 | 33 | fun subtitleOf(session: Session?): String? = session?.subtitle 34 | 35 | fun Session?.subtitleOrPrompt() = subtitleOf(this) ?: "click to enter subtitle" 36 | 37 | object Sessions { 38 | fun sessionWithTitle(s: String): Session? = s.toLowerCase().let { 39 | return when (it) { 40 | learnYouAKotlin.title.toLowerCase() -> learnYouAKotlin 41 | refactoringToStreams.title.toLowerCase() -> refactoringToStreams 42 | else -> null 43 | } 44 | } 45 | } 46 | 47 | 48 | -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part3/Json.kt: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.solution.part3 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException 4 | import com.fasterxml.jackson.databind.JsonNode 5 | import com.fasterxml.jackson.databind.ObjectMapper 6 | import com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT 7 | import com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS 8 | import com.fasterxml.jackson.databind.node.IntNode 9 | import com.fasterxml.jackson.databind.node.JsonNodeFactory 10 | import com.fasterxml.jackson.databind.node.ObjectNode 11 | 12 | private val nodes = JsonNodeFactory.instance 13 | private val stableMapper = ObjectMapper().enable(INDENT_OUTPUT, ORDER_MAP_ENTRIES_BY_KEYS) 14 | 15 | infix fun String.of(textValue: String): Pair { 16 | return of(nodes.textNode(textValue)) 17 | } 18 | 19 | infix fun String.of(intValue: Int): Pair { 20 | return of(IntNode.valueOf(intValue)) 21 | } 22 | 23 | infix fun String.of(value: JsonNode) = this to value 24 | 25 | fun obj(props: Iterable?>) = nodes.objectNode().apply { 26 | props.forEach { p -> if (p != null) set(p.first, p.second) } 27 | } 28 | 29 | fun obj(vararg props: Pair?): ObjectNode { 30 | return obj(props.toList()) 31 | } 32 | 33 | fun array(elements: Iterable) = nodes.arrayNode().apply { elements.forEach { add(it) } } 34 | fun array(elements: List, fn: (T) -> JsonNode) = array(elements.map(fn)) 35 | 36 | fun JsonNode.asStableJsonString(): String { 37 | try { 38 | return stableMapper.writeValueAsString(this) 39 | } 40 | catch (e: JsonProcessingException) { 41 | throw IllegalArgumentException("failed to convert JsonNode to JSON string", e) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part3/JsonFormat.kt: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.solution.part3 2 | 3 | import com.fasterxml.jackson.databind.JsonMappingException 4 | import com.fasterxml.jackson.databind.JsonNode 5 | import learnyouakotlin.solution.part1.Presenter 6 | import learnyouakotlin.solution.part1.Session 7 | import learnyouakotlin.solution.part3.Result.Failure 8 | 9 | fun Session.asJson() = obj( 10 | "title" of title, 11 | subtitle?.let { "subtitle" of it }, 12 | "slots" of obj("first" of slots.start, "last" of slots.endInclusive), 13 | "presenters" of array(presenters, Presenter::asJson)) 14 | 15 | fun JsonNode.toSession() = apply(::Session, 16 | path("title").asNonblankText(), 17 | path("subtitle").asOptionalNonblankText(), 18 | path("slots").toIntRange(), 19 | path("presenters").all(JsonNode::toPresenter)) 20 | 21 | 22 | fun Presenter.asJson() = obj("name" of name) 23 | fun JsonNode.toPresenter() = apply(::Presenter, path("name").asNonblankText()) 24 | 25 | fun JsonNode.asOptionalNonblankText() = if (isNull || isMissingNode) Result.Success(null) else asNonblankText() 26 | 27 | fun JsonNode.asNonblankText() = asText().let { 28 | when { 29 | it.isBlank() -> jsonFailure("blank text is invalid") 30 | else -> Result.Success(it) 31 | } 32 | } 33 | 34 | fun JsonNode.toIntRange(): Result { 35 | return apply(::IntRange, 36 | path("first").toInt(), 37 | path("last").toInt()) 38 | } 39 | 40 | fun JsonNode.toInt() : Result { 41 | return if (isInt) Result.Success(asInt()) else Failure(NumberFormatException("not an int")) 42 | } 43 | 44 | private fun JsonNode.map(transform: (JsonNode) -> T) = elements().asSequence().map(transform).toList() 45 | private fun JsonNode.all(transform: (JsonNode) -> Result) = map(transform).flatten() 46 | 47 | private fun jsonFailure(message: String) = Failure(JsonMappingException(null, message)) 48 | 49 | -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part3/JsonFormatTests.kt: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.solution.part3 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.oneeyedmen.okeydoke.junit.ApprovalsRule 5 | import learnyouakotlin.solution.part1.Presenter 6 | import learnyouakotlin.solution.part1.Session 7 | import org.hamcrest.MatcherAssert.assertThat 8 | import org.hamcrest.core.IsEqual.equalTo 9 | import org.junit.Rule 10 | import org.junit.Test 11 | 12 | class JsonFormatTests { 13 | @Rule 14 | @JvmField 15 | val approval = ApprovalsRule.fileSystemRule("sample-solution") 16 | 17 | @Test 18 | fun session_to_json() { 19 | val session = Session( 20 | "Learn You a Kotlin For All The Good It Will Do You", 21 | null, 22 | 1..2, 23 | Presenter("Duncan McGregor"), 24 | Presenter("Nat Pryce")) 25 | 26 | approval.assertApproved(session.asJson(), JsonNode::asStableJsonString) 27 | } 28 | 29 | @Test 30 | fun session_with_subtitle_to_json() { 31 | val session = Session( 32 | "Scrapheap Challenge", 33 | "A Workshop in Postmodern Programming", 34 | 3..3, 35 | Presenter("Ivan Moore")) 36 | 37 | approval.assertApproved(session.asJson(), JsonNode::asStableJsonString) 38 | } 39 | 40 | @Test 41 | fun session_to_and_from_json() { 42 | val original = Session( 43 | "Working Effectively with Legacy Tests", null, 44 | 4..5, 45 | Presenter("Nat Pryce"), 46 | Presenter("Duncan McGregor")) 47 | 48 | assertThat(original.asJson().toSession().value, equalTo(original)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part3/JsonFormatTests.session_to_json.approved: -------------------------------------------------------------------------------- 1 | { 2 | "title" : "Learn You a Kotlin For All The Good It Will Do You", 3 | "slots" : { 4 | "first" : 1, 5 | "last" : 2 6 | }, 7 | "presenters" : [ { 8 | "name" : "Duncan McGregor" 9 | }, { 10 | "name" : "Nat Pryce" 11 | } ] 12 | } -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part3/JsonFormatTests.session_with_subtitle_to_json.approved: -------------------------------------------------------------------------------- 1 | { 2 | "title" : "Scrapheap Challenge", 3 | "subtitle" : "A Workshop in Postmodern Programming", 4 | "slots" : { 5 | "first" : 3, 6 | "last" : 3 7 | }, 8 | "presenters" : [ { 9 | "name" : "Ivan Moore" 10 | } ] 11 | } -------------------------------------------------------------------------------- /sample-solution/learnyouakotlin/solution/part3/Result.kt: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.solution.part3 2 | 3 | import java.util.* 4 | 5 | sealed class Result { 6 | abstract fun map(f: (T) -> U): Result 7 | abstract fun flatMap(f: (T) -> Result): Result 8 | abstract val value: T? 9 | abstract val error: Throwable? 10 | 11 | class Success(override val value: T) : Result() { 12 | override val error: Throwable? get() = null 13 | override fun map(f: (T) -> U) = Success(f(value)) 14 | override fun flatMap(f: (T) -> Result) = f(value) 15 | } 16 | 17 | class Failure(override val error: Throwable) : Result() { 18 | override val value: Nothing? get() = null 19 | override fun map(f: (Nothing) -> U) = this 20 | override fun flatMap(f: (Nothing) -> Result) = this 21 | } 22 | } 23 | 24 | fun apply(f: (A) -> T, ra: Result) = ra.map(f) 25 | 26 | fun apply(f: (A, B) -> T, ra: Result, rb: Result) = 27 | ra.flatMap { a -> rb.flatMap { b -> Result.Success(f(a, b)) } } 28 | 29 | fun apply(f: (A, B, C) -> T, ra: Result, rb: Result, rc: Result) = 30 | ra.flatMap { a -> rb.flatMap { b -> rc.flatMap { c -> Result.Success(f(a, b, c)) } } } 31 | 32 | fun apply(f: (A, B, C, D) -> T, ra: Result, rb: Result, rc: Result, rd: Result) = 33 | ra.flatMap { a -> rb.flatMap { b -> rc.flatMap { c -> rd.flatMap { d -> Result.Success(f(a, b, c, d)) } } } } 34 | 35 | fun apply(f: (A, B, C, D, E) -> T, ra: Result, rb: Result, rc: Result, rd: Result, re: Result) = 36 | ra.flatMap { a -> rb.flatMap { b -> rc.flatMap { c -> rd.flatMap { d -> re.flatMap { e -> Result.Success(f(a, b, c, d, e)) } } } } } 37 | 38 | fun Iterable>.flatten(): Result> = Result.Success(fold(ArrayList(), { list, result -> 39 | when (result) { 40 | is Result.Success -> list.apply { add(result.value) } 41 | is Result.Failure -> return@flatten result 42 | } 43 | })) 44 | 45 | -------------------------------------------------------------------------------- /scripts/md-to-pdf: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-shell 2 | #!nix-shell -i bash -p pandoc plantuml pandoc-plantuml-filter bash 3 | # 4 | # Run in the root of the project. E.g: 5 | # ./scripts/md-to-pdf INSTRUCTIONS.part4.md 6 | # 7 | set -euo pipefail 8 | 9 | cd build/ 10 | mkdir -p docs 11 | pandoc ../"$1" -o "docs/$(basename $1 .md).pdf" \ 12 | --filter=pandoc-plantuml 13 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'learn-you-a-kotlin' 2 | 3 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part1/Presenter.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part1; 2 | 3 | import java.util.Objects; 4 | 5 | public class Presenter { 6 | public final String name; 7 | 8 | public Presenter(String name) { 9 | this.name = name; 10 | } 11 | 12 | @Override 13 | public boolean equals(Object o) { 14 | if (this == o) return true; 15 | if (o == null || getClass() != o.getClass()) return false; 16 | Presenter presenter = (Presenter) o; 17 | return Objects.equals(name, presenter.name); 18 | } 19 | 20 | @Override 21 | public int hashCode() { 22 | return Objects.hash(name); 23 | } 24 | 25 | @Override 26 | public String toString() { 27 | return name; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part1/Session.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part1; 2 | 3 | import org.jetbrains.annotations.Nullable; 4 | 5 | import java.util.ArrayList; 6 | import java.util.Collections; 7 | import java.util.List; 8 | import java.util.Objects; 9 | 10 | import static java.util.Arrays.asList; 11 | 12 | 13 | public class Session { 14 | public final String title; 15 | @Nullable 16 | public final String subtitle; 17 | public final Slots slots; 18 | public final List presenters; 19 | 20 | public Session(String title, @Nullable String subtitle, Slots slots, List presenters) { 21 | this.title = title; 22 | this.subtitle = subtitle; 23 | this.slots = slots; 24 | this.presenters = Collections.unmodifiableList(new ArrayList<>(presenters)); 25 | } 26 | 27 | public Session(String title, @Nullable String subtitle, Slots slots, Presenter... presenters) { 28 | this(title, subtitle, slots, asList(presenters)); 29 | } 30 | 31 | @Override 32 | public boolean equals(Object o) { 33 | if (this == o) return true; 34 | if (o == null || getClass() != o.getClass()) return false; 35 | Session session = (Session) o; 36 | return Objects.equals(title, session.title) && 37 | Objects.equals(subtitle, session.subtitle) && 38 | Objects.equals(slots, session.slots) && 39 | Objects.equals(presenters, session.presenters); 40 | } 41 | 42 | @Override 43 | public int hashCode() { 44 | return Objects.hash(title, subtitle, slots, presenters); 45 | } 46 | 47 | @Override 48 | public String toString() { 49 | return "Session{" + 50 | "title='" + title + '\'' + 51 | ", subtitle='" + subtitle + '\'' + 52 | ", slots=" + slots + 53 | ", presenters=" + presenters + 54 | '}'; 55 | } 56 | 57 | public Session withPresenters(List newLineUp) { 58 | return new Session(title, subtitle, slots, newLineUp); 59 | } 60 | 61 | public Session withTitle(String newTitle) { 62 | return new Session(newTitle, subtitle, slots, presenters); 63 | } 64 | 65 | public Session withSubtitle(@Nullable String newSubtitle) { 66 | return new Session(title, newSubtitle, slots, presenters); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part1/Slots.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part1; 2 | 3 | import java.util.Objects; 4 | 5 | public class Slots { 6 | public final int start, endInclusive; 7 | 8 | public Slots(int start, int endInclusive) { 9 | this.start = start; 10 | this.endInclusive = endInclusive; 11 | } 12 | 13 | @Override 14 | public boolean equals(Object o) { 15 | if (this == o) return true; 16 | if (o == null || getClass() != o.getClass()) return false; 17 | Slots slots = (Slots) o; 18 | return start == slots.start && 19 | endInclusive == slots.endInclusive; 20 | } 21 | 22 | @Override 23 | public int hashCode() { 24 | return Objects.hash(start, endInclusive); 25 | } 26 | 27 | @Override 28 | public String toString() { 29 | return "Slots{" + 30 | "start=" + start + 31 | ", endInclusive=" + endInclusive + 32 | '}'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part2/Sessions.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part2; 2 | 3 | import learnyouakotlin.part1.Session; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | import java.util.List; 7 | 8 | class Sessions { 9 | public static @Nullable String subtitleOf(@Nullable Session session) { 10 | if (session == null) 11 | return null; 12 | else 13 | return session.subtitle; 14 | } 15 | 16 | public static String subtitleOrPrompt(Session session) { 17 | if (session.subtitle == null) 18 | return "click to enter subtitle"; 19 | else 20 | return session.subtitle; 21 | } 22 | 23 | public static @Nullable Session findWithTitle(List sessions, String title) { 24 | return sessions.stream().filter(session -> session.title.equalsIgnoreCase(title)).findFirst().orElse(null); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part3/Json.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part3; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.JsonNode; 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import com.fasterxml.jackson.databind.node.*; 7 | 8 | import java.util.AbstractMap.SimpleImmutableEntry; 9 | import java.util.List; 10 | import java.util.Map; 11 | import java.util.function.Function; 12 | 13 | import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT; 14 | import static com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS; 15 | import static java.util.Arrays.asList; 16 | import static java.util.stream.Collectors.toList; 17 | 18 | public class Json { 19 | private static final JsonNodeFactory nodes = JsonNodeFactory.instance; 20 | static final ObjectMapper stableMapper = new ObjectMapper().enable(INDENT_OUTPUT, ORDER_MAP_ENTRIES_BY_KEYS); 21 | 22 | public static Map.Entry prop(String name, String textValue) { 23 | return prop(name, new TextNode(textValue)); 24 | } 25 | 26 | public static Map.Entry prop(String name, int intValue) { 27 | return prop(name, new IntNode(intValue)); 28 | } 29 | 30 | public static Map.Entry prop(String name, JsonNode value) { 31 | return new SimpleImmutableEntry<>(name, value); 32 | } 33 | 34 | public static ObjectNode obj(Iterable> props) { 35 | ObjectNode object = new ObjectNode(nodes); 36 | props.forEach(p -> { 37 | // p can be null, but no way to annotate the Map.Entry within the Iterable 38 | if (p != null) { 39 | object.set(p.getKey(), p.getValue()); 40 | } 41 | }); 42 | return object; 43 | } 44 | 45 | @SafeVarargs 46 | public static ObjectNode obj(Map.Entry... props) { 47 | // Elements of props may be null, but there's no way to use annotations to indicate that. Annotating the 48 | // props parameter with @Nullable means that the whole array may be null 49 | return obj(asList(props)); 50 | } 51 | 52 | public static ArrayNode array(Iterable elements) { 53 | ArrayNode array = new ArrayNode(nodes); 54 | elements.forEach(array::add); 55 | return array; 56 | } 57 | 58 | public static ArrayNode array(List elements, Function fn) { 59 | return array(elements.stream().map(fn).collect(toList())); 60 | } 61 | 62 | public static String toStableJsonString(JsonNode n) { 63 | try { 64 | return stableMapper.writeValueAsString(n); 65 | } catch (JsonProcessingException e) { 66 | throw new IllegalArgumentException("failed to convert JsonNode to JSON string", e); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part3/JsonFormat.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part3; 2 | 3 | import com.fasterxml.jackson.databind.JsonMappingException; 4 | import com.fasterxml.jackson.databind.JsonNode; 5 | import com.fasterxml.jackson.databind.node.ObjectNode; 6 | import learnyouakotlin.part1.Presenter; 7 | import learnyouakotlin.part1.Session; 8 | import learnyouakotlin.part1.Slots; 9 | import org.jetbrains.annotations.Nullable; 10 | 11 | import java.util.List; 12 | import java.util.Objects; 13 | import java.util.Spliterator; 14 | import java.util.stream.Collectors; 15 | 16 | import static java.util.stream.StreamSupport.stream; 17 | import static learnyouakotlin.part3.Json.*; 18 | 19 | public class JsonFormat { 20 | 21 | public static JsonNode sessionToJson(Session session) { 22 | return obj( 23 | prop("title", session.title), 24 | session.subtitle == null ? null : prop("subtitle", session.subtitle), 25 | prop("slots", obj( 26 | prop("first", session.slots.start), 27 | prop("last", session.slots.endInclusive) 28 | )), 29 | prop("presenters", array(session.presenters, JsonFormat::presenterToJson))); 30 | } 31 | 32 | public static Session sessionFromJson(JsonNode json) throws JsonMappingException { 33 | String title = nonBlankText(json.path("title")); 34 | @Nullable String subtitle = optionalNonBlankText(json.path("subtitle")); 35 | 36 | JsonNode authorsNode = json.path("presenters"); 37 | List presenters = stream(spliterator(authorsNode::elements), false) 38 | .map(JsonFormat::presenterFromJson) 39 | .collect(Collectors.toList()); 40 | Slots slots = new Slots(json.at("/slots/first").intValue(), json.at("/slots/last").intValue()); 41 | 42 | return new Session(title, subtitle, slots, presenters); 43 | } 44 | 45 | private static Spliterator spliterator(Iterable elements) { 46 | return elements.spliterator(); 47 | } 48 | 49 | private static ObjectNode presenterToJson(Presenter p) { 50 | return obj(prop("name", p.name)); 51 | } 52 | 53 | private static Presenter presenterFromJson(JsonNode authorNode) { 54 | return new Presenter(authorNode.path("name").asText()); 55 | } 56 | 57 | private static 58 | @Nullable 59 | String optionalNonBlankText(JsonNode node) throws JsonMappingException { 60 | if (node.isMissingNode()) { 61 | return null; 62 | } else { 63 | return nonBlankText(node); 64 | } 65 | } 66 | 67 | private static String nonBlankText(JsonNode node) throws JsonMappingException { 68 | String text = node.asText(); 69 | if (node.isNull() || Objects.equals(text, "")) { 70 | throw new JsonMappingException(null, "missing or empty text"); 71 | } else { 72 | return text; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part4/AttendeeId.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | public final class AttendeeId extends Identifier { 4 | private AttendeeId(String value) { 5 | super(value); 6 | } 7 | 8 | public static AttendeeId of(String value) { 9 | return new AttendeeId(value); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part4/Identifier.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | import java.util.Objects; 4 | 5 | public abstract class Identifier { 6 | private final String value; 7 | 8 | protected Identifier(String value) { 9 | this.value = value; 10 | if (value == null || value.isEmpty()) { 11 | throw new IllegalArgumentException("empty value"); 12 | } 13 | } 14 | 15 | @Override 16 | public boolean equals(Object o) { 17 | if (this == o) return true; 18 | if (o == null || getClass() != o.getClass()) return false; 19 | Identifier that = (Identifier) o; 20 | return value.equals(that.value); 21 | } 22 | 23 | @Override 24 | public int hashCode() { 25 | return Objects.hash(value); 26 | } 27 | 28 | @Override 29 | public String toString() { 30 | return getClass().getSimpleName() + ":" + value; 31 | } 32 | 33 | public String getValue() { 34 | return value; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part4/SessionId.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | public final class SessionId extends Identifier { 4 | private SessionId(String value) { 5 | super(value); 6 | } 7 | 8 | public static SessionId of(String value) { 9 | return new SessionId(value); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part4/SignupBook.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | public interface SignupBook { 7 | @Nullable SignupSheet sheetFor(SessionId session); 8 | 9 | void save(SignupSheet signup); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part4/SignupHttpHandler.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | import com.sun.net.httpserver.HttpExchange; 4 | import com.sun.net.httpserver.HttpHandler; 5 | import jakarta.ws.rs.core.Response; 6 | import org.glassfish.jersey.uri.UriTemplate; 7 | import org.jetbrains.annotations.Nullable; 8 | 9 | import java.io.IOException; 10 | import java.io.OutputStreamWriter; 11 | import java.util.HashMap; 12 | import java.util.List; 13 | import java.util.stream.Collectors; 14 | 15 | import static jakarta.ws.rs.HttpMethod.*; 16 | import static jakarta.ws.rs.core.Response.Status.*; 17 | 18 | 19 | public class SignupHttpHandler implements HttpHandler { 20 | public static final UriTemplate signupsRoute = 21 | new UriTemplate("/sessions/{sessionId}/signups"); 22 | public static final UriTemplate signupRoute = 23 | new UriTemplate("/sessions/{sessionId}/signups/{attendeeId}"); 24 | public static final UriTemplate closedRoute = 25 | new UriTemplate("/sessions/{sessionId}/closed"); 26 | 27 | public static final List routes = 28 | List.of(signupsRoute, signupRoute, closedRoute); 29 | 30 | 31 | private final Transactor transactor; 32 | 33 | public SignupHttpHandler(Transactor transactor) { 34 | this.transactor = transactor; 35 | } 36 | 37 | @Override 38 | public void handle(HttpExchange exchange) throws IOException { 39 | final var params = new HashMap(); 40 | 41 | final var matchedRoute = matchRoute(exchange, params); 42 | if (matchedRoute == null) { 43 | sendResponse(exchange, NOT_FOUND, "resource not found"); 44 | return; 45 | } 46 | 47 | transactor.perform(book -> { 48 | final var sheet = book.sheetFor(SessionId.of(params.get("sessionId"))); 49 | if (sheet == null) { 50 | sendResponse(exchange, NOT_FOUND, "session not found"); 51 | return; 52 | } 53 | 54 | if (matchedRoute == signupsRoute) { 55 | handleSignups(exchange, sheet); 56 | } else if (matchedRoute == signupRoute) { 57 | handleSignup(exchange, book, sheet, AttendeeId.of(params.get("attendeeId"))); 58 | } else if (matchedRoute == closedRoute) { 59 | handleClosed(exchange, book, sheet); 60 | } 61 | }); 62 | } 63 | 64 | private void handleSignups(HttpExchange exchange, SignupSheet sheet) throws IOException { 65 | switch (exchange.getRequestMethod()) { 66 | case GET -> { 67 | sendResponse(exchange, OK, 68 | sheet.getSignups().stream() 69 | .map(Identifier::getValue) 70 | .collect(Collectors.joining("\n"))); 71 | } 72 | default -> { 73 | sendMethodNotAllowed(exchange); 74 | } 75 | } 76 | } 77 | 78 | private void handleSignup(HttpExchange exchange, SignupBook book, SignupSheet sheet, AttendeeId attendeeId) throws IOException { 79 | switch (exchange.getRequestMethod()) { 80 | case GET -> { 81 | sendResponse(exchange, OK, sheet.isSignedUp(attendeeId)); 82 | } 83 | case POST -> { 84 | try { 85 | sheet.signUp(attendeeId); 86 | book.save(sheet); 87 | sendResponse(exchange, OK, "subscribed"); 88 | } catch (IllegalStateException e) { 89 | sendResponse(exchange, CONFLICT, e.getMessage()); 90 | } 91 | } 92 | case DELETE -> { 93 | try { 94 | sheet.cancelSignUp(attendeeId); 95 | book.save(sheet); 96 | sendResponse(exchange, OK, "unsubscribed"); 97 | } catch (IllegalStateException e) { 98 | sendResponse(exchange, CONFLICT, e.getMessage()); 99 | } 100 | } 101 | default -> { 102 | sendMethodNotAllowed(exchange); 103 | } 104 | } 105 | } 106 | 107 | private void handleClosed(HttpExchange exchange, SignupBook book, SignupSheet sheet) throws IOException { 108 | switch (exchange.getRequestMethod()) { 109 | case GET -> { 110 | sendResponse(exchange, OK, sheet.isClosed()); 111 | } 112 | case POST -> { 113 | sheet.close(); 114 | book.save(sheet); 115 | sendResponse(exchange, OK, "closed"); 116 | } 117 | default -> { 118 | sendMethodNotAllowed(exchange); 119 | } 120 | } 121 | } 122 | 123 | private static @Nullable UriTemplate matchRoute(HttpExchange exchange, HashMap paramsOut) { 124 | for (final var t : routes) { 125 | if (t.match(exchange.getRequestURI().getPath(), paramsOut)) { 126 | return t; 127 | } 128 | } 129 | return null; 130 | } 131 | 132 | private static void sendResponse(HttpExchange exchange, Response.Status status, Object bodyValue) throws IOException { 133 | exchange.getResponseHeaders().add("Content-Type", "text/plain"); 134 | exchange.sendResponseHeaders(status.getStatusCode(), 0); 135 | final var body = new OutputStreamWriter(exchange.getResponseBody()); 136 | body.write(bodyValue.toString()); 137 | body.flush(); 138 | } 139 | 140 | private static void sendMethodNotAllowed(HttpExchange exchange) throws IOException { 141 | sendResponse(exchange, METHOD_NOT_ALLOWED, 142 | exchange.getRequestMethod() + " method not allowed"); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part4/SignupSheet.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.util.LinkedHashSet; 6 | import java.util.Set; 7 | 8 | public class SignupSheet { 9 | private SessionId sessionId; 10 | private int capacity; 11 | private final LinkedHashSet signups = new LinkedHashSet<>(); 12 | private boolean isClosed = false; 13 | 14 | public SignupSheet() { 15 | } 16 | 17 | public SignupSheet(@NotNull SessionId sessionId, int capacity) { 18 | this.sessionId = sessionId; 19 | this.capacity = capacity; 20 | } 21 | 22 | public SessionId getSessionId() { 23 | return sessionId; 24 | } 25 | 26 | public void setSessionId(SessionId sessionId) { 27 | if (sessionId != null) { 28 | throw new IllegalStateException("you cannot change the sessionId after it has been set"); 29 | } 30 | this.sessionId = sessionId; 31 | } 32 | 33 | public int getCapacity() { 34 | return capacity; 35 | } 36 | 37 | public void setCapacity(int newCapacity) { 38 | if (capacity != 0) { 39 | throw new IllegalStateException("you cannot change the capacity after it has been set"); 40 | } 41 | 42 | this.capacity = newCapacity; 43 | } 44 | 45 | public boolean isFull() { 46 | return signups.size() == capacity; 47 | } 48 | 49 | public boolean isClosed() { 50 | return isClosed; 51 | } 52 | 53 | public Set getSignups() { 54 | return Set.copyOf(signups); 55 | } 56 | 57 | public boolean isSignedUp(AttendeeId attendeeId) { 58 | return signups.contains(attendeeId); 59 | } 60 | 61 | public void signUp(AttendeeId attendeeId) { 62 | if (isClosed()) throw new IllegalStateException("sign-up has closed"); 63 | if (isFull()) throw new IllegalStateException("session is full"); 64 | 65 | signups.add(attendeeId); 66 | } 67 | 68 | public void cancelSignUp(AttendeeId attendeeId) { 69 | if (isClosed()) throw new IllegalStateException("sign-up has closed"); 70 | 71 | signups.remove(attendeeId); 72 | } 73 | 74 | public void close() { 75 | isClosed = true; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/learnyouakotlin/part4/Transactor.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | import java.io.IOException; 4 | 5 | public interface Transactor { 6 | interface Query { 7 | T work(Resource resource) throws IOException; 8 | } 9 | 10 | interface Update { 11 | void work(Resource resource) throws IOException; 12 | } 13 | 14 | T perform(Query work) throws IOException; 15 | 16 | void perform(Update work) throws IOException; 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part1/SessionTests.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part1; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import static java.util.Arrays.asList; 6 | import static org.junit.jupiter.api.Assertions.assertEquals; 7 | 8 | public class SessionTests { 9 | 10 | Session original = new Session("The Title", null, new Slots(1, 2), new Presenter("Alice")); 11 | 12 | @Test 13 | public void can_change_presenters() { 14 | assertEquals( 15 | new Session("The Title", null, new Slots(1, 2), new Presenter("Bob"), new Presenter("Carol")), 16 | original.withPresenters(asList(new Presenter("Bob"), new Presenter("Carol")))); 17 | } 18 | 19 | @Test 20 | public void can_change_title() { 21 | assertEquals( 22 | new Session("Another Title", null, new Slots(1, 2), new Presenter("Alice")), 23 | original.withTitle("Another Title")); 24 | } 25 | 26 | @Test 27 | public void can_change_subtitle() { 28 | assertEquals( 29 | new Session("The Title", "The Subtitle", new Slots(1, 2), new Presenter("Alice")), 30 | original.withSubtitle("The Subtitle")); 31 | } 32 | 33 | @Test 34 | public void can_remove_subtitle() { 35 | assertEquals( 36 | new Session("The Title", null, new Slots(1, 2), new Presenter("Alice")), 37 | original.withSubtitle("The Subtitle").withSubtitle(null)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part2/SessionsTests.kt.later: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part2 2 | 3 | import learnyouakotlin.part1.Session 4 | import learnyouakotlin.part1.Slots 5 | import org.junit.jupiter.api.Test 6 | import org.junit.jupiter.api.Assertions.assertEquals 7 | import org.junit.jupiter.api.Assertions.assertNull 8 | 9 | class SessionsTests { 10 | 11 | companion object { 12 | val learnYouAKotlin = Session("Learn you a kotlin", "for all the good it will do you", Slots(1, 1)) 13 | val refactoringToStreams = Session("Refactoring to Streams", null, Slots(2, 2)) 14 | } 15 | 16 | val sessions = listOf(learnYouAKotlin, refactoringToStreams) 17 | 18 | @Test 19 | fun `nulls and flow typing`() { 20 | val session: Session? = Sessions.findWithTitle(sessions, "learn you a kotlin") 21 | 22 | // Uncomment to see that this can't compile 23 | // session.subtitle 24 | 25 | val notNullSession = session as Session 26 | assertEquals("for all the good it will do you", notNullSession.subtitle) 27 | assertEquals("for all the good it will do you", session.subtitle) 28 | } 29 | 30 | @Test 31 | fun `null safe access`() { 32 | assertEquals("for all the good it will do you", Sessions.subtitleOf(learnYouAKotlin)) 33 | assertNull(Sessions.subtitleOf(null)) 34 | } 35 | 36 | @Test 37 | fun subtitleOrPrompt() { 38 | assertEquals("for all the good it will do you", Sessions.subtitleOrPrompt(learnYouAKotlin)) 39 | assertEquals("click to enter subtitle", Sessions.subtitleOrPrompt(refactoringToStreams)) 40 | } 41 | 42 | @Test 43 | fun find() { 44 | assertEquals(refactoringToStreams, Sessions.findWithTitle(sessions, "refactoring to streams")) 45 | assertNull(Sessions.findWithTitle(sessions, "nosuch")) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part3/JsonFormatTests.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part3; 2 | 3 | import com.fasterxml.jackson.databind.JsonMappingException; 4 | import com.fasterxml.jackson.databind.JsonNode; 5 | import com.oneeyedmen.okeydoke.Approver; 6 | import com.oneeyedmen.okeydoke.junit5.ApprovalsExtension; 7 | import learnyouakotlin.part1.Presenter; 8 | import learnyouakotlin.part1.Session; 9 | import learnyouakotlin.part1.Slots; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.api.extension.ExtendWith; 12 | 13 | import java.io.IOException; 14 | 15 | import static learnyouakotlin.part3.JsonFormat.sessionFromJson; 16 | import static learnyouakotlin.part3.JsonFormat.sessionToJson; 17 | import static org.junit.jupiter.api.Assertions.assertEquals; 18 | import static org.junit.jupiter.api.Assertions.fail; 19 | 20 | @ExtendWith(ApprovalsExtension.class) 21 | public class JsonFormatTests { 22 | @Test 23 | public void session_to_json(Approver approval) { 24 | Session session = new Session( 25 | "Learn You a Kotlin For All The Good It Will Do You", 26 | null, 27 | new Slots(1, 2), 28 | new Presenter("Duncan McGregor"), 29 | new Presenter("Nat Pryce")); 30 | 31 | JsonNode json = sessionToJson(session); 32 | approval.assertApproved(Json.toStableJsonString(json)); 33 | } 34 | 35 | @Test 36 | public void session_with_subtitle_to_json(Approver approval) { 37 | Session session = new Session( 38 | "Scrapheap Challenge", 39 | "A Workshop in Postmodern Programming", 40 | new Slots(3, 3), 41 | new Presenter("Ivan Moore")); 42 | 43 | JsonNode json = sessionToJson(session); 44 | approval.assertApproved(Json.toStableJsonString(json)); 45 | } 46 | 47 | @Test 48 | public void session_to_and_from_json() throws JsonMappingException { 49 | Session original = new Session( 50 | "Working Effectively with Legacy Tests", 51 | null, 52 | new Slots(4, 5), 53 | new Presenter("Nat Pryce"), 54 | new Presenter("Duncan McGregor")); 55 | 56 | Session parsed = sessionFromJson(sessionToJson(original)); 57 | assertEquals(original, parsed); 58 | } 59 | 60 | @Test 61 | public void reading_throws_with_blank_subtitle() throws IOException { 62 | String json = ("{" + 63 | " 'title' : 'Has blank subtitle'," + 64 | " 'subtitle' : ''," + 65 | " 'slots' : { 'first' : 3, 'last' : 3 }," + 66 | " 'presenters' : [ { 'name' : 'Ivan Moore' } ]\n" + 67 | "}").replace("'", "\""); 68 | try { 69 | sessionFromJson(Json.stableMapper.readTree(json)); 70 | fail(); 71 | } catch (JsonMappingException expected) { 72 | assertEquals("missing or empty text", expected.getMessage()); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part3/JsonFormatTests.session_to_json.approved: -------------------------------------------------------------------------------- 1 | { 2 | "title" : "Learn You a Kotlin For All The Good It Will Do You", 3 | "slots" : { 4 | "first" : 1, 5 | "last" : 2 6 | }, 7 | "presenters" : [ { 8 | "name" : "Duncan McGregor" 9 | }, { 10 | "name" : "Nat Pryce" 11 | } ] 12 | } -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part3/JsonFormatTests.session_with_subtitle_to_json.approved: -------------------------------------------------------------------------------- 1 | { 2 | "title" : "Scrapheap Challenge", 3 | "subtitle" : "A Workshop in Postmodern Programming", 4 | "slots" : { 5 | "first" : 3, 6 | "last" : 3 7 | }, 8 | "presenters" : [ { 9 | "name" : "Ivan Moore" 10 | } ] 11 | } -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part4/InMemoryHttpExchange.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | import com.sun.net.httpserver.Headers; 4 | import com.sun.net.httpserver.HttpContext; 5 | import com.sun.net.httpserver.HttpExchange; 6 | import com.sun.net.httpserver.HttpPrincipal; 7 | 8 | import java.io.*; 9 | import java.net.InetSocketAddress; 10 | import java.net.URI; 11 | import java.nio.charset.StandardCharsets; 12 | 13 | public class InMemoryHttpExchange extends HttpExchange { 14 | private final String requestMethod; 15 | private final URI requestUri; 16 | private final Headers requestHeaders; 17 | private final ByteArrayInputStream requestBody; 18 | private int responseCode = 0; 19 | private final Headers responseHeaders = new Headers(); 20 | private long expectedResponseBodySize = 0; 21 | private final ByteArrayOutputStream responseBody = new ByteArrayOutputStream() { 22 | @Override 23 | public void close() throws IOException { 24 | super.close(); 25 | checkResponseBodySize(); 26 | } 27 | }; 28 | 29 | private void checkResponseBodySize() { 30 | if (expectedResponseBodySize > 0 && responseBody.size() != expectedResponseBodySize) { 31 | throw new IllegalStateException("incorrect response body size sent: " + 32 | "expected " + expectedResponseBodySize + ", was " + responseBody.size()); 33 | } 34 | } 35 | 36 | public InMemoryHttpExchange(String requestMethod, URI requestUri, Headers requestHeaders, ByteArrayInputStream requestBody) { 37 | this.requestMethod = requestMethod; 38 | this.requestUri = requestUri; 39 | this.requestHeaders = requestHeaders; 40 | this.requestBody = requestBody; 41 | } 42 | 43 | public InMemoryHttpExchange(String requestMethod, String requestUri) { 44 | this(requestMethod, requestUri, noHeaders(), noBody()); 45 | } 46 | 47 | public InMemoryHttpExchange(String requestMethod, String requestUri, Headers requestHeaders, ByteArrayInputStream requestBody) { 48 | this(requestMethod, URI.create(requestUri), requestHeaders, requestBody); 49 | } 50 | 51 | public static Headers noHeaders() { 52 | return new Headers(); 53 | } 54 | 55 | public static ByteArrayInputStream noBody() { 56 | return new ByteArrayInputStream(new byte[0]); 57 | } 58 | 59 | public static ByteArrayInputStream utf8Body(String textBody) { 60 | return new ByteArrayInputStream(textBody.getBytes(StandardCharsets.UTF_8)); 61 | } 62 | 63 | @Override 64 | public Headers getRequestHeaders() { 65 | return requestHeaders; 66 | } 67 | 68 | @Override 69 | public Headers getResponseHeaders() { 70 | return responseHeaders; 71 | } 72 | 73 | @Override 74 | public URI getRequestURI() { 75 | return requestUri; 76 | } 77 | 78 | @Override 79 | public String getRequestMethod() { 80 | return requestMethod; 81 | } 82 | 83 | @Override 84 | public HttpContext getHttpContext() { 85 | throw new UnsupportedOperationException(); 86 | } 87 | 88 | @Override 89 | public void close() { 90 | try { 91 | requestBody.close(); 92 | responseBody.close(); 93 | } catch (IOException e) { 94 | throw new UncheckedIOException(e); 95 | } 96 | } 97 | 98 | @Override 99 | public ByteArrayInputStream getRequestBody() { 100 | return requestBody; 101 | } 102 | 103 | @Override 104 | public ByteArrayOutputStream getResponseBody() { 105 | if (responseCode == 0) { 106 | throw new IllegalStateException("response headers have not been sent"); 107 | } 108 | return responseBody; 109 | } 110 | 111 | @Override 112 | public void sendResponseHeaders(int rCode, long responseLength) throws IOException { 113 | responseCode = rCode; 114 | expectedResponseBodySize = responseLength; 115 | } 116 | 117 | @Override 118 | public InetSocketAddress getRemoteAddress() { 119 | throw new UnsupportedOperationException(); 120 | } 121 | 122 | @Override 123 | public int getResponseCode() { 124 | return responseCode; 125 | } 126 | 127 | @Override 128 | public InetSocketAddress getLocalAddress() { 129 | throw new UnsupportedOperationException(); 130 | } 131 | 132 | @Override 133 | public String getProtocol() { 134 | return requestUri.getScheme(); 135 | } 136 | 137 | @Override 138 | public Object getAttribute(String name) { 139 | throw new UnsupportedOperationException(); 140 | } 141 | 142 | @Override 143 | public void setAttribute(String name, Object value) { 144 | throw new UnsupportedOperationException(); 145 | } 146 | 147 | @Override 148 | public void setStreams(InputStream i, OutputStream o) { 149 | throw new UnsupportedOperationException(); 150 | } 151 | 152 | @Override 153 | public HttpPrincipal getPrincipal() { 154 | throw new UnsupportedOperationException(); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part4/InMemorySignupBook.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | import org.jetbrains.annotations.Nullable; 4 | 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | 8 | public class InMemorySignupBook implements SignupBook { 9 | private final Map signupsById = new HashMap<>(); 10 | 11 | @Override 12 | public @Nullable SignupSheet sheetFor(SessionId session) { 13 | SignupSheet stored = signupsById.get(session); 14 | if (stored == null) { 15 | return null; 16 | } else { 17 | // Return a copy of the sheet, to emulate behaviour of database 18 | SignupSheet loaded = new SignupSheet(stored.getSessionId(), stored.getCapacity()); 19 | stored.getSignups().forEach(loaded::signUp); 20 | if (stored.isClosed()) { 21 | loaded.close(); 22 | } 23 | return loaded; 24 | } 25 | } 26 | 27 | @Override 28 | public void save(SignupSheet signup) { 29 | signupsById.put(signup.getSessionId(), signup); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part4/InMemoryTransactor.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | import java.io.IOException; 4 | 5 | public class InMemoryTransactor implements Transactor { 6 | private final Resource resource; 7 | 8 | public InMemoryTransactor(Resource resource) { 9 | this.resource = resource; 10 | } 11 | 12 | @Override 13 | public T perform(Query work) throws IOException { 14 | return work.work(resource); 15 | } 16 | 17 | @Override 18 | public void perform(Update work) throws IOException { 19 | work.work(resource); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part4/SessionSignupHttpTests.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | import com.sun.net.httpserver.HttpExchange; 4 | import com.sun.net.httpserver.HttpHandler; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.io.IOException; 8 | import java.io.UncheckedIOException; 9 | import java.util.LinkedHashSet; 10 | import java.util.Map; 11 | import java.util.Set; 12 | import java.util.UUID; 13 | import java.util.function.Predicate; 14 | 15 | import static jakarta.ws.rs.HttpMethod.*; 16 | import static jakarta.ws.rs.core.Response.Status.CONFLICT; 17 | import static jakarta.ws.rs.core.Response.Status.Family.SUCCESSFUL; 18 | import static jakarta.ws.rs.core.Response.Status.Family.familyOf; 19 | import static java.nio.charset.StandardCharsets.UTF_8; 20 | import static java.util.stream.Collectors.toCollection; 21 | import static learnyouakotlin.part4.SignupHttpHandler.*; 22 | import static org.junit.jupiter.api.Assertions.assertEquals; 23 | import static org.junit.jupiter.api.Assertions.assertTrue; 24 | 25 | 26 | public class SessionSignupHttpTests { 27 | private static final AttendeeId alice = AttendeeId.of("alice"); 28 | private static final AttendeeId bob = AttendeeId.of("bob"); 29 | private static final AttendeeId carol = AttendeeId.of("carol"); 30 | private static final AttendeeId dave = AttendeeId.of("dave"); 31 | 32 | private final SessionId exampleSessionId = SessionId.of(UUID.randomUUID().toString()); 33 | 34 | private final InMemorySignupBook book = new InMemorySignupBook(); 35 | 36 | private final HttpHandler api = new SignupHttpHandler(new InMemoryTransactor<>(book)); 37 | 38 | 39 | @Test 40 | public void collects_signups() { 41 | book.save(new SignupSheet(exampleSessionId, 15)); 42 | 43 | assertEquals(Set.of(), getSignups(exampleSessionId)); 44 | 45 | signUp(exampleSessionId, alice); 46 | assertEquals(Set.of(alice), getSignups(exampleSessionId)); 47 | 48 | signUp(exampleSessionId, bob); 49 | assertEquals(Set.of(alice, bob), getSignups(exampleSessionId)); 50 | 51 | signUp(exampleSessionId, carol); 52 | assertEquals(Set.of(alice, bob, carol), getSignups(exampleSessionId)); 53 | 54 | signUp(exampleSessionId, dave); 55 | assertEquals(Set.of(alice, bob, carol, dave), getSignups(exampleSessionId)); 56 | } 57 | 58 | @Test 59 | public void each_attendee_can_only_sign_up_once() { 60 | book.save(new SignupSheet(exampleSessionId, 3)); 61 | 62 | signUp(exampleSessionId, alice); 63 | signUp(exampleSessionId, alice); 64 | signUp(exampleSessionId, alice); 65 | 66 | assertEquals(Set.of(alice), getSignups(exampleSessionId)); 67 | } 68 | 69 | @Test 70 | public void can_only_sign_up_to_capacity() { 71 | book.save(new SignupSheet(exampleSessionId, 3)); 72 | 73 | signUp(exampleSessionId, alice); 74 | signUp(exampleSessionId, bob); 75 | signUp(exampleSessionId, carol); 76 | 77 | signUp(failsWithConflict, exampleSessionId, dave); 78 | } 79 | 80 | @Test 81 | public void cancelling_a_signup_frees_capacity_when_not_full() { 82 | book.save(new SignupSheet(exampleSessionId, 15)); 83 | 84 | signUp(exampleSessionId, alice); 85 | signUp(exampleSessionId, bob); 86 | signUp(exampleSessionId, carol); 87 | 88 | cancelSignUp(exampleSessionId, carol); 89 | assertEquals(Set.of(alice, bob), getSignups(exampleSessionId)); 90 | 91 | signUp(exampleSessionId, dave); 92 | assertEquals(Set.of(alice, bob, dave), getSignups(exampleSessionId)); 93 | } 94 | 95 | @Test 96 | public void cancelling_a_signup_frees_capacity_when_full() { 97 | book.save(new SignupSheet(exampleSessionId, 3)); 98 | 99 | signUp(exampleSessionId, alice); 100 | signUp(exampleSessionId, bob); 101 | signUp(exampleSessionId, carol); 102 | 103 | cancelSignUp(exampleSessionId, bob); 104 | assertEquals(Set.of(alice, carol), getSignups(exampleSessionId)); 105 | 106 | signUp(exampleSessionId, dave); 107 | assertEquals(Set.of(alice, carol, dave), getSignups(exampleSessionId)); 108 | } 109 | 110 | @Test 111 | public void cannot_sign_up_when_sheet_closed() { 112 | book.save(new SignupSheet(exampleSessionId, 3)); 113 | 114 | signUp(exampleSessionId, alice); 115 | signUp(exampleSessionId, bob); 116 | 117 | assertTrue(!isSessionClosed(exampleSessionId)); 118 | closeSession(exampleSessionId); 119 | 120 | assertTrue(isSessionClosed(exampleSessionId)); 121 | signUp(failsWithConflict, exampleSessionId, carol); 122 | } 123 | 124 | @Test 125 | public void cannot_cancel_a_sign_up_after_sheet_closed() { 126 | book.save(new SignupSheet(exampleSessionId, 3)); 127 | 128 | signUp(exampleSessionId, alice); 129 | signUp(exampleSessionId, bob); 130 | closeSession(exampleSessionId); 131 | 132 | cancelSignUp(failsWithConflict, exampleSessionId, alice); 133 | } 134 | 135 | @Test 136 | public void closing_sheet_is_idempotent() { 137 | book.save(new SignupSheet(exampleSessionId, 3)); 138 | 139 | signUp(exampleSessionId, alice); 140 | 141 | closeSession(exampleSessionId); 142 | closeSession(exampleSessionId); 143 | 144 | signUp(failsWithConflict, exampleSessionId, carol); 145 | cancelSignUp(failsWithConflict, exampleSessionId, alice); 146 | } 147 | 148 | @Test 149 | public void can_close_an_empty_sheet() { 150 | book.save(new SignupSheet(exampleSessionId, 3)); 151 | 152 | closeSession(exampleSessionId); 153 | signUp(failsWithConflict, exampleSessionId, carol); 154 | } 155 | 156 | 157 | private void signUp(SessionId sessionId, AttendeeId attendeeId) { 158 | signUp(isSuccessful, sessionId, attendeeId); 159 | } 160 | 161 | private void signUp(Predicate expectedOutcome, SessionId sessionId, AttendeeId attendeeId) { 162 | apiCall(expectedOutcome, POST, signupRoute.createURI(Map.of( 163 | "sessionId", sessionId.getValue(), 164 | "attendeeId", attendeeId.getValue()))); 165 | } 166 | 167 | private void cancelSignUp(SessionId sessionId, AttendeeId attendeeId) { 168 | cancelSignUp(isSuccessful, sessionId, attendeeId); 169 | } 170 | 171 | private void cancelSignUp(Predicate expectedResult, SessionId sessionId, AttendeeId attendeeId) { 172 | apiCall(expectedResult, DELETE, signupRoute.createURI(Map.of( 173 | "sessionId", sessionId.getValue(), 174 | "attendeeId", attendeeId.getValue()))); 175 | } 176 | 177 | private Set getSignups(SessionId sessionId) { 178 | return apiCall(isSuccessful, GET, signupsRoute.createURI(Map.of( 179 | "sessionId", sessionId.getValue())) 180 | ).lines() 181 | .map(AttendeeId::of) 182 | .collect(toCollection(LinkedHashSet::new)); 183 | } 184 | 185 | private boolean isSessionClosed(SessionId sessionId) { 186 | return Boolean.parseBoolean(apiCall(isSuccessful, GET, closedRoute.createURI(Map.of( 187 | "sessionId", sessionId.getValue())))); 188 | } 189 | 190 | private void closeSession(SessionId sessionId) { 191 | apiCall(isSuccessful, POST, closedRoute.createURI(Map.of( 192 | "sessionId", sessionId.getValue()))); 193 | } 194 | 195 | private String apiCall(Predicate expectedResult, String method, String uri) { 196 | final var exchange = new InMemoryHttpExchange(method, uri); 197 | 198 | try { 199 | api.handle(exchange); 200 | } catch (IOException e) { 201 | throw new UncheckedIOException(e); 202 | } 203 | 204 | assertTrue(expectedResult.test(exchange), () -> "expected " + expectedResult); 205 | 206 | return exchange.getResponseBody().toString(UTF_8); 207 | } 208 | 209 | private static final Predicate failsWithConflict = new Predicate<>() { 210 | @Override 211 | public String toString() { 212 | return "fails with conflict"; 213 | } 214 | 215 | @Override 216 | public boolean test(HttpExchange exchange) { 217 | return exchange.getResponseCode() == CONFLICT.getStatusCode(); 218 | } 219 | }; 220 | 221 | private static final Predicate isSuccessful = new Predicate<>() { 222 | @Override 223 | public String toString() { 224 | return "is successful"; 225 | } 226 | 227 | @Override 228 | public boolean test(HttpExchange exchange) { 229 | return familyOf(exchange.getResponseCode()) == SUCCESSFUL; 230 | } 231 | }; 232 | } 233 | -------------------------------------------------------------------------------- /src/test/java/learnyouakotlin/part4/SignupServer.java: -------------------------------------------------------------------------------- 1 | package learnyouakotlin.part4; 2 | 3 | 4 | import com.sun.net.httpserver.HttpServer; 5 | 6 | import java.io.IOException; 7 | import java.net.InetSocketAddress; 8 | import java.util.concurrent.Executors; 9 | 10 | /** 11 | * Run the signup handler with in-memory storage, for manual testing 12 | */ 13 | public class SignupServer { 14 | public static void main(String[] args) throws IOException { 15 | final var book = new InMemorySignupBook(); 16 | for (int i = 1; i <= 10; i++) { 17 | SignupSheet sheet = new SignupSheet(); 18 | sheet.setSessionId(SessionId.of(Integer.toString(i))); 19 | sheet.setCapacity(20); 20 | book.save(sheet); 21 | } 22 | 23 | int port = 9876; 24 | final var server = HttpServer.create(new InetSocketAddress(port), 0); 25 | // So we don't have to worry that SignupSheet and SignupBook are not thread safe 26 | server.setExecutor(Executors.newSingleThreadExecutor()); 27 | server.createContext("/", new SignupHttpHandler(new InMemoryTransactor<>(book))); 28 | server.start(); 29 | 30 | System.out.println("Ready at http://localhost:"+port); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /website/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Refactoring to Kotlin Workshop 6 | 7 | 8 | 77 | 78 | 79 | 95 | 96 |
97 |
98 |

99 | Working as a group, we'll take existing Java code, convert it to Kotlin in IntelliJ, and then refactor the 100 | results to idiomatic Kotlin. We'll see how to take advantage of the language to make code smaller, faster 101 | and more expressive, whilst at the same time interoperating with your existing application and Java 102 | libraries. 103 |

104 |

105 | In the afternoon we move from the prepared exercises to look at your code, working together to apply the 106 | techniques and insights from the morning to real codebases with real constraints. So if you have some code 107 | you'd like to start migrating, bring it along. 108 |

109 |
110 |
111 |

Topics

112 |

113 | The prepared exercises cover the following topics and more: 114 |

    115 |
  • Java v Kotlin Classes
  • 116 |
  • Data Classes
  • 117 |
  • Constructors, Properties, Methods
  • 118 |
  • Calling Java from Kotlin and vice versa
  • 119 |
  • Variables and values
  • 120 |
  • Read-only collections
  • 121 |
  • Extension functions
  • 122 |
  • Nullability
  • 123 |
  • Lambdas
  • 124 |
  • Destructuring
  • 125 |
  • Typealiases
  • 126 |
  • Objects
  • 127 |
  • @JVM* annotations to control Java interop
  • 128 |
  • Exceptions
  • 129 |
  • Functional operations on collections
  • 130 |
  • Mini Kotlin DSLs
  • 131 |
132 |

133 |

134 | In addition the workshop format provides plenty of opportunity to explore other topics and 135 | answer specific questions as they arise. 136 |

137 |
138 |
139 |

The Presenters

140 |

141 | Nat Pryce co-authored the highly respected book, 142 | Growing Object Software Guided by the Tests. 143 | Duncan McGregor didn’t, but manages to get by 144 | regardless. They have a combined 40 years of programming on the JVM, in applications ranging from real-time video 145 | processing to finance.

146 |

Duncan and Nat adopted Kotlin while working together in 2015 and presented their experiences to the first London 147 | Kotlin Meetup. They then talked about 148 | Expressive Kotlin at 149 | JetBrains London Kotlin Night in 2016. Duncan presented 150 | The Cost of Kotlin Language Features at KotlinConf 2017, 151 | and Nat presented 152 | Exploring the Kotlin Type Hierarchy from Top to Bottom 153 | at KotlinConf 2019. 154 |

155 |
156 |
157 |

Workshop History

158 |

159 | The workshop is based on sessions for the British Computer Society, SPA Conference and the London 160 | Java Community. 161 |

162 | It was selected by JetBrains as a KotlinConf 2018 workshop. We are delighted to have been asked to run it 163 | again at KotlinConf 2019. 164 |

165 |
166 |
167 |

Feedback from Previous Sessions

168 |
    169 |
  • "Great intro to Kotlin, lots of crossover and functionality"
  • 170 |
  • "Good at highlighting what’s different about Kotlin compared to Java or Scala"
  • 171 |
  • "Love the energy and interaction with clear explanation"
  • 172 |
  • "The speakers were good at speaking. The talk itself was well thought out and a great introduction to 173 | Kotlin. 174 | It was obvious the guy knew his stuff with the answers he gave to questions. The ability to ask 175 | questions as we went on was very useful." 176 |
  • 177 |
  • "I liked the pair programming and live coding aspects of the event. It's really engaging."
  • 178 |
  • "It was very smoothly executed and good to have someone on keyboard with someone else talking so it flowed well." 179 |
  • 180 |
  • "The presentation was lively and full of personality."
  • 181 |
  • "Very interactive workshop, it was particularly nice to have only 8 attendees, it allowed nice digressions and 182 | we were able to tackle a lot of items."
  • 183 |
184 |
185 |
186 |

Workshop Dates

187 |

4 December 2019

188 |

The workshop is being held as part of KotlinConf 2019 in Copenhagen.

189 |

If you are already attending the conference, you can add the workshop to your conference pass.

190 |
191 |
192 | 207 | 208 | --------------------------------------------------------------------------------