Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

29KB

FOCUS Architecture — Chapter-07: Pure Functions and Immutability

  • Source: /library/FOCUS Architecture/source-file.pdf
  • PDF pages: 127–157
  • Pages without text: none

Pure Functions and Immutability In this chapter, you’ll: classify any function as pure or impure with a one-line test, and justify the call out loud; refactor the tab-total calculation by extracting the pure core calculateTotal(items, customer) , with a three-line test and no mock; write the idiomatic immutable Item in your own language, with the copy-with-change move for each of the ten. The same tab went through Rosie’s register twice and printed two totals: $41.80 on the first call, $39.52 on the second. No item was added, no item was removed; the code just ran again. You’re going to find the culprit, pull a pure function out of it, and leave this chapter with the cheapest, highest-return fix in the whole book. Chapter 6 closed on a promise: write business rules as pure functions and SRP stops being discipline and starts being a consequence. This chapter pays that promise, in the currency chapter 4 minted: there, simplicity meant deciding what the code does NOT do; here, purity is that same decision applied one function at a time. A pure function doesn’t read a singleton, doesn’t write to a database, doesn’t log, doesn’t check the clock. What’s left is little. And that little is exactly where the business rule lives, clean enough to test in three lines.

Two totals for the same tab Friday night at the coffee shop. The clerk rings up table 4’s tab: two cappuccinos at $11.95, a ham and cheese toast at $8.45, a brownie at $9.45. The screen shows $41.80, and the customer asks to split the check. The clerk taps “recalculate,” and the same tab, with no other change, answers $39.52. Rosie charges the lower amount, to be safe, and forwards you the screenshot. Here’s the code that answered both calls. One word before you read on: a singleton is the class that exists in exactly one instance in the whole program, reachable from anywhere through a static field. Hold onto the term, because it’s the first of three guests. Dart // The singleton marketing changes whenever it wants. class DiscountConfig { static final instance = DiscountConfig(); int percentage = 0; } class Item {

Item(this.name, this.priceInCents, this.quantity); final String name; int priceInCents; final int quantity; } int calculateTabTotal(List items) { // Sums the tab's items. var sum = 0; for (final item in items) { sum += item.priceInCents * item.quantity; } // Reads the percentage from the singleton: anyone could have changed it.

final percentage = DiscountConfig.instance.percentage; final total = sum - (sum * percentage) ~/ 100; // Rounds prices for the receipt and MUTATES the list it received. for (final item in items) { item.priceInCents -= item.priceInCents % 10; } // Writes the register's log. print("[register] total calculated: $total”); return total; } Prices are in cents, following the convention chapter 5 set, and the function has three guests who don’t pay rent. It reads the percentage from a singleton that any part of the app can change. It rounds every price down to the nearest multiple of 10 by

writing INTO the list it received, because someone once decided the receipt looked nicer with round prices. And it logs a line. Each guest charges a different kind of pain. The first pain: the result can’t be reproduced. Between the first call and the second, marketing turned on the loyalty promotion and the singleton went from 0% to 5%. Same list, different total, and nothing in the function’s signature warns you. The second pain: the function can’t be tested without setup. To write a test you have to prime the singleton with the right value beforehand and, to check the log, capture console output. The test ends up testing the function plus the whole global scenario surrounding it. The third pain is the sneakiest: call order matters. The first call rounded the prices inside the very list it received. The second call got handed a tab that no longer matches the menu: cappuccino at $11.90, ham and cheese toast at $8.40, brownie at $9.40. Now the math checks out. On the first call, the faithful sum is 4180 cents and the discount is zero: $41.80. On the way out, the mutation shaves the prices down and the tab loses 20 cents nobody asked to give up. On the second call, the sum of the already-mutilated list is 4160, the singleton’s 5% takes off another 208, and the screen prints $39.52. Of the $2.28 difference, $0.20 came from the mutation and $2.08 from the singleton. Two independent bugs, invisible to each other, inside the same function body. Purity is what a function doesn’t do Time to name the test. A pure function obeys two clauses: the same input always produces the same output, and nothing happens besides the output. Everything the function does beyond

returning a result is a side effect, a term borrowed from pharmacology: the pill treats the headache and, off the label, makes you drowsy. Reading the singleton breaks the first clause, because the output now depends on something that never came in through the front door. Mutating (changing something in place) the list and writing the log break the second, because the world is different after the call than it was before. Applying the test to the register’s method hands you the refactor’s whole script for free: the singleton becomes a parameter, the mutation becomes a read, the log becomes a returned value. Here’s the result: Dart · TypeScript · Kotlin class Customer { const Customer(this.name, {required this.loyaltyActive}); final String name; final bool loyaltyActive; } class Total { const Total(

this.subtotalInCents, this.discountInCents, this.totalInCents, ); final int subtotalInCents; final int discountInCents; final int totalInCents; } // The pure core: same input, same output, zero side effects. Total calculateTotal(List items, Customer customer) { // Sums the items without touching the list it received. var subtotal = 0; for (final item in items) {

subtotal += item.priceInCents * item.quantity; } // The customer arrives as a parameter; the singleton is dead. final discount = customer.loyaltyActive ? (subtotal * 5) ~/ 100 : 0; // Returns the result instead of logging it. return Total(subtotal, discount, subtotal - discount); } A word about the three symbols stacked over a single listing: it’s written in Dart, and in this chapter’s TypeScript and Kotlin sources the function is the same line by line, just spelled differently (the Customer and Total classes become type in TypeScript and data class in Kotlin; the integer division ~/ becomes Math.trunc and Int ’s / ). When the translation is that direct, the book prints a single listing with the symbols stacked, the way chapter 2 set up. Read the signature first, because now it tells the whole truth: calculateTotal takes items and a customer, returns a Total , done. The summing loop only reads item.priceInCents ; no line writes to the list, so the tab that comes in is the tab that goes out. The configuration singleton became the customer parameter, and eligibility for the discount travels inside it as loyaltyActive ;

whoever wants a different percentage tomorrow edits an explicit business rule, not a piece of global state. The log disappeared from the body: instead of printing, the function returns a Total with subtotal, discount, and total broken out, and whoever called it decides what to display. Receipt rounding is gone for good, because a receipt is a formatting concern, and chapter 6 already gave you the name of whoever handles that. Call this function two hundred times with the same tab and the same customer, and it returns the same Total two hundred times. The two-totals bug wasn’t fixed. It became impossible to write. Try it: run https://focus.kodel.com.br/en/dart/07-01 or https://focus.kodel.com.br/en/ts/07-01. The snippet calls the impure method twice and then calls calculateTotal twice, always with the same tab. Before you run it, write down your prediction for the four numbers. Ours: 4180 and 3952 for the impure pair, 4180 and 4180 for the pure pair. Swap the call for the returned value Purity buys you a property with a fancy name and a practical consequence. An expression has referential transparency when the call can be swapped for its returned value without changing the program’s behavior. The name comes straight from logic: the reference (the call) is transparent because all that sits behind it is the referent (the value). For table 4’s tab with loyalty active, writing calculateTotal(items, ana) or writing Total(4180, 209, 3971) is the exact same thing, at any point in the program, in any order, however many times you like. Try that with calculateTabTotal and the program changes: the fixed value doesn’t mutate any list and doesn’t print any log.

Substituting a call for a value is exactly what a test does: it asserts that the call on the left equals the result on the right. With a pure core, the test shrinks down to three lines: Dart · TypeScript · Kotlin // The test: just data, a call, and a check. No mock, no setup. void testCalculateTotal() { // Arrange: build the input. const customer = Customer(“Ana”, loyaltyActive: true); // Act: call the pure core. final total = calculateTotal([Item(“Cappuccino”, 1195, 2)], customer); // Assert: check the output. assert(total.totalInCents == 2271); } The comments name the classic test rhythm, arrange, act, assert, and each step fit on one line: build the customer, call the function, check the total. The math checks out: two cappuccinos add up to 2390, the 5% takes off 119, and 2271 is what’s left.

There’s no mock, a stand-in object that takes the place of a real dependency during a test, because there’s no dependency to fake. There’s no setup or teardown, because there’s no state to prepare or clean up. There’s no waiting (async/await), because there’s no I/O (input/output: the program’s conversation with disk, network, and screen). Compare that with the impure method’s test, which needed to prime the singleton and capture the console. A pure function is the ideal unit of test, and chapter 17 builds the whole pyramid on top of this foundation. Freeze the data: immutability across ten languages The pure core promises not to mutate the list it receives, but so far that promise is just good manners: the list is still mutable, and so is the priceInCents field on the anti-pattern’s Item . What’s missing is closing the door from the data side. A value has immutability when, once built, it never changes again; to “change” an immutable value, you produce a copy with the new field, and that move has a name: copy-with-change. The two ideas are the other side of purity (a reminder, in one sentence: a pure function always returns the same output for the same input, with no side effect). Data that can’t change turns the function’s promise into something the compiler can verify, and that’s exactly where the ten languages part ways: each one enforces the promise with a different amount of force. The far north of that scale is Rust, where immutability isn’t an option, it’s the default: Rust #[derive(Clone)]

struct Item { name: String, price_in_cents: u32, quantity: u32, } fn main() { let cappuccino = Item { name: String::from(“Cappuccino”), price_in_cents: 1195, quantity: 2, }; // cappuccino.price_in_cents = 1095; // compiler error: cappuccino wasn't declared with mut

// Copy-with-change: struct update syntax. let promotional = Item { price_in_cents: 1095, ..cappuccino.clone() }; println!("{}", cappuccino.price_in_cents); println!("{}", promotional.price_in_cents); } Every let is immutable until you write mut , and copy-with- change is baked into the language’s syntax: ..cappuccino.clone() fills in every field you didn’t change. Whoever wants to mutate has to ask for it in writing. This is the one scenario where the language itself guarantees it, and the other nine get measured by how far they land from here. The next family down enforces it at the field level. Dart, Kotlin, and Swift share the same construct under three different names: the keyword that declares a field which only accepts a value at construction is final in Dart, val in Kotlin, and let in Swift. Once the fields are locked, each one offers its own idiomatic move for

copying. From here on, this chapter’s Item is the one below, with priceInCents frozen; the mutable-field version from the opening was the anti-pattern, and it dies right here: Dart · Kotlin · Swift // The same Item from the opening, rewritten: all three fields lock at // construction, and the only way to “change” the price is to produce // another Item. class Item { const Item({ required this.name, required this.priceInCents, required this.quantity, }); final String name; final int priceInCents; final int quantity;

Item copyWith({int? priceInCents}) { return Item( name: name, priceInCents: priceInCents ?? this.priceInCents, quantity: quantity, ); } } copyWith is written by hand (or generated by a package), and the call reads cappuccino.copyWith(priceInCents: 1095) : the original stays intact. A data class hands you the same move for free: val on the fields and cappuccino.copy(priceInCents = 1095) , without writing a single method. The path is different and the effect is the same: a struct copies by value on assignment, so var promotional = cappuccino is already the copy, and changing promotional.priceInCents never touches the cappuccino declared with let . Java and C# solved it with the same keyword, record , and only diverge on the copy: Java · C# record Item(String name, int priceInCents, int quantity) {

Item withPrice(int newPriceInCents) { return new Item(name, newPriceInCents, quantity); } } Java 21’s record freezes every field, but copy-with-change is a method you write yourself, like withPrice above; the language doesn’t generate that move for you. C# generates it: the with operator produces the copy with the fields swapped, cappuccino with { PriceInCents = 1095 } , no hand-written method needed. Two records that look identical, one with a native copy gesture and one with a manual one: that’s the only difference that matters between them. TypeScript and Python make their promise on the type checker’s paper, and you need to know that BEFORE you trust the register to either of them: TypeScript · Python type Item = { readonly name: string; readonly priceInCents: number; readonly quantity: number; };

const cappuccino: Item = { name: “Cappuccino”, priceInCents: 1195, quantity: 2, }; // cappuccino.priceInCents = 1095; // COMPILER error: Cannot assign to ‘priceInCents’ // because it is a read-only property // Copy-with-change: spread with the new field on top. const promotional: Item = { ...cappuccino, priceInCents: 1095 }; // The guarantee gets erased along with the types: nothing protects // you at runtime. (cappuccino as { priceInCents: number }).priceInCents = 999;

console.log(the runtime let it through: ${cappuccino.priceInCents}); readonly blocks the assignment at the compiler level, and the spread { ...cappuccino, priceInCents: 1095 } is the idiomatic copy- with-change. Except the types get erased at compile time: the JavaScript that runs in production accepts the write the editor refused, as the last three lines prove. One misplaced as , or one piece of data that arrived from outside the type checker, and “immutable” changes. @dataclass(frozen=True) stands one step higher: assigning to cappuccino.price_in_cents raises a FrozenInstanceError in plain runtime, and the copy comes out through replace(cappuccino, price_in_cents=1095) . That extra step, though, isn’t a vault: object.setattr(cappuccino, “price_in_cents”, 999) breaks the freeze with one line. In both languages, readonly and frozen are contracts between people, enforced by static analysis while you develop ( tsc in one case, mypy in the other), not by the machine that runs the code in production. Go has no final , readonly , frozen , or record . What it has is pass-by- value, and the team’s discipline does the rest: Go type Item struct { Name string PriceInCents int Quantity int }

// Receives by value: works on a copy, the original stays intact. func withPromotionalPrice(item Item) Item { item.PriceInCents = 1095 return item } // Receives a pointer: touches the original. The signature gives // away the intent. func lowerPrice(item *Item) { item.PriceInCents = 999 } When Item travels by value, every function gets its own copy and the caller is protected by construction. When it travels by pointer ( *Item ), or when the field is a slice (Go’s version of a “list”: a window into an array that lives somewhere else, so copying the struct copies the window, not the data behind it), the protection ends: whoever receives it writes to the original. The compiler doesn’t weigh in; the signature is the only warning you get. So here’s what the team promises instead: domain types travel by value, a pointer only shows up where mutation is the

declared goal, and a slice inside a domain struct gets copied before it’s stored. That’s a code-review promise, not a compiler one. PHP closes out the list with a similar promise and one extra tool: PHP final class Item { public function __construct( public readonly string $name, public readonly int $priceInCents, public readonly int $quantity, ) { } public function withPrice(int $newPriceInCents): Item { return new Item($this->name, $newPriceInCents, $this->quantity);

} } PHP 8.2’s readonly locks the property after the constructor runs; any write attempt after that raises a runtime error, and copy-with-change is a method that builds a fresh instance, like withPrice . The team’s promise here is one of coverage: readonly on EVERY property of every domain class, no exceptions, because one forgotten mutable property reopens the door the others closed. Hold onto that contrast, first name and last name: in Rust, the language guarantees it; in Go and PHP, the team promises it. The other six languages live somewhere between those two extremes, and knowing which rung your language stands on decides how much code review immutability is going to cost you. This Item and this Tab , by the way, aren’t disposable: chapter 8 returns errors on top of them, and chapter 14 uses them as input for the use cases. Try it: run https://focus.kodel.com.br/en/go/07-02. The snippet passes the same Item to both functions in the listing above. Prediction: the by-value version prints 1195 for the original and 1095 for the copy; the by-pointer version prints 999 for the “original,” because the “copy” never existed. Functional core, imperative shell

One bill from the refactor is still open: the log and the configuration existed for a reason. The register really does need to record the total in the shop’s console, and the loyalty promotion really does need to come from some directory. If purity bans both from living inside the function, where do they go? Into a thin shell wrapped around it: Dart · TypeScript · Kotlin // The imperative shell: reads the world, calls the core, writes the log. Total closeTab(List items) { final customer = lookupCustomerAtRegister(); final total = calculateTotal(items, customer); print("[register] total: ${total.totalInCents}"); return total; } Four lines of body, each with one job. The first reads the world: it looks up the customer (and her eligibility) in the register’s directory. The second hands everything to the functional core and gets back the Total . The third writes the log the impure method used to write, only now from outside the business rule. The fourth returns the Total to whoever called it, untouched. Nothing was lost in the refactor; the effects just changed address.

This arrangement has a name and a source. Gary Bernhardt, in the talk “Boundaries” (SCNA, 2012) and the screencast “Functional Core, Imperative Shell” (Destroy All Software, 2012), named the pattern functional core, imperative shell: the program’s decisions become pure functions at the center, and a thin, dumb shell of effects wraps around that center to read inputs and dispatch outputs. The tab’s flow looks like this: The arrows spell out the rule: side effects are born and die only in the shell, and the core just takes values and returns values. Notice the shell doesn’t decide anything; it just ferries things back and forth. If the log turns into a database write tomorrow, or the directory turns into a network call, calculateTotal never finds out, and the three-line test keeps passing without touching a mock.

The pattern draws two objections, and both deserve an answer instead of silence. The first: “a useful program writes to a database and logs; total purity is a fantasy.” The objection gets the fact right and misses the target, because nobody asked for total purity. Bernhardt (2012) asks for something else: that DECISIONS live in pure functions, and that effects live in a shell with no decisions of its own. Rosie’s app keeps writing logs, reading the directory, and charging cards; it just stops mixing those jobs with the total’s arithmetic. A fantasy would be a program with no effects at all; functional core with imperative shell is Friday night with the register closing out right. The second: “immutability costs performance, copying an object on every change is wasteful.” It does cost something, and I’ll stake out the position: in ten years of business apps, I have never once seen a three-field object’s copyWith show up in a profiler, the tool that measures where a program actually spends time and memory, instead of where you think it does. I make an exception in exactly two spots: byte buffers (the raw slice of memory where images, audio, and network packets travel) and hot loops (the loop that runs millions of times and dominates total runtime, flagged by the profiler). In those two, I mutate inside a function that never lets the mutation leak out. Everywhere else, trading the safety of a frozen value for microseconds nobody ever measured is selling lunch to buy dessert. Pitfalls The sort that mutates in place. Mainstream languages’ list methods love to edit the very list you called them on and hand back the result as a courtesy. You sort “a copy” and discover the original changed right along with it:

Dart // Pitfall 1: sort orders the list IN PLACE. final menu = [“Cappuccino”, “Brownie”, “Cheese bread”]; final sorted = menu..sort(); print(sorted); print(menu); // the “original” changed too: it's the SAME list The output prints [Brownie, Cappuccino, Cheese bread] TWICE: sorted and menu are the same list wearing two name tags. In Dart, the version that returns a new list is [...menu]..sort() , copy first, sort second; in JavaScript, toSorted() instead of sort() ; in Python, sorted(menu) instead of menu.sort() . When the list really is immutable, the trap at least screams: try prices.sort() on a Dart const list and the runtime answers Unsupported operation: Cannot modify an unmodifiable list . Two variables, one list. Assigning a collection to another variable doesn’t copy anything; it copies the reference: Dart // Pitfall 2: two variables, one single list. final table2Tab = [1195, 845];

final table3Tab = table2Tab; table3Tab.add(945); print(table2Tab); // table 3's brownie landed on table 2's bill The output is [1195, 845, 945] : table 2 is about to pay for a brownie it never ordered, and final didn’t stop any of it, because final locks the variable, not the contents. The fix is copying at the boundary ( [...table2Tab] ), or, in this chapter’s spirit, modeling Tab as an immutable type and producing table 3’s tab through copy- with-change. Q&A Is a function that reads a global constant impure? If the value is truly constant ( const SERVICE_FEE = 10 ), the function is still pure: the constant is part of the code, like a literal. Impurity starts when the global can CHANGE between two calls, like the register’s singleton. What about a function that uses the clock or a random draw? now() and random() break the first clause: same input, different outputs. The fix is the same as the singleton’s: the instant and the seed come in as parameters, and whoever checks the clock is the shell. Where do I find Bernhardt’s material? The talk is at destroyallsoftware.com/talks/boundaries and the screencast at destroyallsoftware.com/screencasts/catalog/functional- core-imperative-shell. The two together cost under an hour, and the talk alone is worth the price of admission.

Quick tip Let the analyzer watch immutability for you: in Dart, turn on the prefer_final_locals and prefer_final_fields lints; in TypeScript, the prefer-readonly rule from typescript-eslint; in Python, run mypy, the tool that actually enforces frozen . A promise a machine collects on is a promise the team keeps. Tip 7 If a function needs setup to be tested, it isn’t a function yet: it’s a method in disguise. Quick reference Language Who enforces it Copy-with-change Rust the language (default) Item { price_in_cents: 1095, ..item.clone() } Dart compiler: final / const item.copyWith(priceInCents: 1095) Kotlin compiler: val item.copy(priceInCents = 1095) Swift compiler: struct by value var c = item; c.priceInCents = 1095 Java compiler: record (Java 21) your own method: item.withPrice(1095) C# compiler: record + item with { PriceInCents = 1095 }

init TypeScript type checker: readonly { ...item, priceInCents: 1095 } Python frozen=True (breakable) replace(item, price_in_cents=1095) Go the team: structs by value pass by value, return the copy PHP runtime: readonly (PHP 8.2) constructor: $item-

withPrice(1095) Exercises

  1. Classify each of the five functions below as pure or impure, and justify your call by the chapter’s test (same input, same output, zero side effects). Watch for the two traps: size isn’t the test. Dart // Function 1 String formatPrice(int cents) { return “$${cents ~/ 100}.${(cents % 100).toString().padLeft(2, “0”)}"; }

// Function 2 int nextTabNumber() => ++_tabCounter; // Function 3 int calculateLoyaltyDiscount(int subtotalInCents, Customer customer) { if (customer.purchasesThisMonth < 5) { return 0; } final int percentage; if (customer.purchasesThisMonth >= 20) { percentage = 15; } else if (customer.purchasesThisMonth >= 10) { percentage = 10; } else {

percentage = 5; } final discount = (subtotalInCents * percentage) ~/ 100; const capInCents = 2000; return discount > capInCents ? capInCents : discount; } // Function 4 void recordSale(Tab tab) { _database.add(tab); } // Function 5 List sortByPrice(List items) {

items.sort((a, b) => a.priceInCents.compareTo(b.priceInCents)); return items; } Answer key: function 1 is pure; formatting 4180 returns “$41.80” today, tomorrow, and in the test, without touching anything. Function 2 is impure even at one line: every call returns a different number and also writes to the global counter, which breaks both clauses at once. Function 3 is pure even at twenty lines: tiers, a cap, and branches, all computed from the parameters alone, same input and same output every time. Function 4 is impure the classic way: it writes to the database (a side effect) and returns void; it exists solely for the effect. Function 5 is the chapter’s trap: it looks like a query, but sort reorders the received list in place, and the caller gets back the very same list, now scrambled; impure by argument mutation. If you called function 2 pure for being short, or function 3 impure for being long, reread the test: it never mentions size. 2. Could you swap calculateTotal ’s flat 5% discount for function 3’s tiered discount, without breaking purity and without changing the calculateTotal(items, customer) signature? Customer will need to carry purchasesThisMonth , and your language’s copy- with-change move settles the model migration in one line. Next chapter: calculateTotal returns a tidy Total when everything goes right, but Rosie’s business runs on cases that go wrong: so what does your pure function return when the customer doesn’t exist, the loyalty card has expired, or the tab arrives empty, given that throwing an exception is itself a side effect?

Powered by TurnKey Linux.