Let's take the example of memory management: by pushing that complexity into the type system, Rust forces the programmer to deal with it and design around it. At the expense of some performance, we could instead push this complexity into a runtime garbage collection system. Since the runtime system understands things about the runtime characteristics of the program that can't be proven via static analysis, it can also handle more things without the programmer having to intervene, thus reducing the difficulty of the programming language. For most programmers this is a positive tradeoff (since most programmers are not writing software where every microsecond matters).
Similar tradeoffs exist in many different areas of software engineering. One monolith, where all the information is in one place, is easier to write code in than two microservices, which keep having to ask each other questions via API call. Yet, sometimes we need microservices. Rendering your web application entirely on the frontend in React, or entirely on the backend with templates, where all the logic lives in one place, is much easier than doing server-sided rendering then hydrating on the frontend. Yet, sometimes we need server-sided rendering and hydration.
Complexity is an irreducible constant, yes, but cognitive load is not. Cognitive load can increase or decrease depending on where you choose to push your complexity.
'Necessary complexity' needs to live somewhere. There is often a core of complexity that is intrinsic to the problem being solved. This cannot be removed, only moved/converted/etc..
That doesn't mean everything needs to be complex, and that you need to 'collect' it somewhere. There is such a thing as unnecessary complexity, and code that has a lot of it is 'bad code'. Don't fall for the trap of thinking that you can't improve code by identifying and eliminating unnecessary complexity.
So where to push the complexity should be dependent on who will be interacting with it and what they consider "cognitive load".
As an additional example:
> Rendering your web application entirely on the frontend in React, or entirely on the backend with templates, where all the logic lives in one place, is much easier than doing server-sided rendering then hydrating on the frontend.
For an expert front-end React developer, rendering entirely in the backend with Jinja templates would be higher cognitive load than the other options, even if it is technically simpler.
However, complexity also comes from the solution itself — caching, microservice architecture, or even poorly chosen variable names.
So, complexity is irreducible, but it’s not a constant.
Certain solutions partition the problem space, thereby partitioning the complexity. This reduces the local complexity and, consequently, the cognitive load. However, the global complexity still remains and can even increase.
The way I primarily see (and often like) type systems wrt complexity is as choosing which parts of complexity are important and exposing them (and rest being still there to deal with). There is a cognitive aspect to abstractions and complexity, irrespective even of IDEs, debuggers, compilers etc. I personally want my abstractions to make at least some sense in my head or a piece of paper in the way I think about the problem before even I start writing code. If the abstractions do not help me actually cognise about (some part of) the problem, they probably solve other problems, not mine.
I'd say that TDD being more popular in untyped languages speaks against TDD, as it hints that maybe some of its benefits are covered already by a type system.
I don't think it is lack of types at fault for untyped languages liking TDD (though I miss types a lot). I think it is there is no way to find out if functions exist until runtime (most allow self modifying code of some form so a static analysis can't verify without solving the halting problem). Though once you know a function exists the next step of verifying the function (or an overload in some languages) exists does need types.
If you look at any well tested program in a dynamic language, almost all the tests check the same properties that a type system would also check by default. If you remove those, usually only a few remain that test non-trivial properties.
EDIT: And I just love that in the time I took to write this, somebody wrote a comment about how it isn't so. No, it is still blatantly obvious.
One attacks the problem of bugs from the bottom up and the other from the top down. They both have diminishing returns on investment the closer they get to overlapping on covering the same types of bug.
The haskell bros who think tests dont do anything useful because "a good type system covers all bugs" themselves havent really delivered anything useful.
By the nature of type systems, they are tightly coupled with the code written around them.
Rust has rich features to handle this coupling (traits and derives), but typescript does not.
It's harder to summarize what Typescript is isolating, except that JavaScript function signatures are the flipping wild west and the type system has to model most of that complexity. It tends to produce very leaky abstractions in my experience unless you put in a lot of work.
For example, `Object.assign` overrides all property with same name. Sometimes you use it to construct a new object, so it is a safe usage. But what about using it to override the buildin object's property? It is definitely going to explode the whole program. However there isn't really a mechanism for typescript to differ the usage is safe or not. So in order to maintain compatibility, typescript just allow both of them.
And typescript in my opinion don't really isolate very much complexity. But it does document what the 'complexity' is. So you can offload your memory tax to it. Put it away, do something else, and resume later by looking at what definition you write before. In this way. It can make managing a big project much easier if you make proper use of it.
> The complexity was always there... it merely shone a light on the existing complexity, and gave us the opportunity — and a tool with which — to start grappling with it
It's not about Rust vs. TypeScript per se but uses garbage collection and borrow checker as examples of two solutions to the same problem. For whatever task you have at hand, what abstractions offer the best value that lets you finish the solution to the satisfaction of constraints?
> they are tightly coupled with the code written around them
Which is where the cost of the abstractions comes in. Part of the struggle is when the software becomes more complicated to manage than the problems solved and abstractions move from benefit to liability. The abstractions of the stack prevent solving problems in a way that isn't bound to our dancing around them.
If I'm working on a high-throughput networked service shuffling bytes using Protobuf, I'm going to be fighting Node to get the most out of CPU and memory. If I'm writing CRUD code in Rust shuffling JSON into an RDBMS I'm going to spending more time writing and thinking about types than I would just shuffling around arbitrarily nested bag-of-bags in Python with compute to spare.
I always thought this was why microservices became popular, because it constrained the problem space of any one project so language abstractions remained net-positives.
That’s what I’m talking about. Encoding complexity in your types does not manage where that complexity lives or where you have to deal with it.
It forces you to deal with that complexity everywhere in your codebase.
Typesystems can be complex to use, but in the end they constrain the degrees of freedom exposed by any given piece of code. With a type systems only very specific things can happen with any part of your code, most of which the programmer may have had in mind — without a type system the number of ways any piece of code could act within the program is way larger. Reducing the possible states of your program in the case of programming error is a reduction of complexity.
Now I don't say type systems may introduce their own complexity, but in the case of Rust the complexity exposed is what systems programmers should handle. E.g. using different String types to signify to the programmer that your OS will not allow all possible strings as file names is the appropriate amount of complexity. Knowing how your program handles these is again reducing complexity.
Imagine you wrote a module in a language where you don't handle these. Every now and then the module crashes specifically because it came across a malformed filename. Or phrased differently: The program does more than you intended, namely crashing when it encounters certain filenames. Good luck figuring that out and preventing it from happening again. With a type system the choice had to be explicitly made during programming already. Less things you code can do, less complexity.
Many developers confuse complexity of the internal workings of a program with the complexity of the program exposed at the interface. These are separate properties that could become linked, but shouldn't.
However, sometimes it's a bit over-rated when all that's needed is some information hiding and indirection, which is what this article appears to be discussing. These tools are the ones that are "leaky" in the sense that the complexity they attempt to hide often escapes the confines of their interface. It tends to give "abstraction" a bad reputation among programmers who have to deal with such systems.
Essential complexity does have to live somewhere. Best to be upfront about it.
However, when I was a kid a would put a firecracker next to an object. I didn't bother running the scenario through a compiler to see if the object was of type Explodable() and had an explode() method that would be called.
Duck typing: if it quacks like a duck, and it explodes objects next to it, it's a firequacker
In slightly different words, an abstraction separates what client code needs to reason about from what it should be able to ignore. Of course, if an abstraction isolates client code from certain complexities, that will contribute to the success of the abstraction. But it’s not the essence of what an abstraction does, or a necessary condition for it to count as successful.
This whole section makes me think of construction which has similar abstraction and hidden complexity problems. It strikes me that they solve it by having design be entirely separate from implementation. Which is usually the corner where all our luck as software developers inevitably runs out.
Our methods are still rather "cowboy." We have cool "modernized cowboy" languages that make it hard to shoot your foot off, but at the end of the day, we're still just riding old horses and hoping for the best.
It’s crazy to see what we’re capable of building now vs even 15 years ago.
The only hard thing in software: papers please (easily accessible documentation)
Let's say you have a poem program, that reads files from your drive and turns them into poems. A well isolated/abstracted variant of that program is as simple as a blackbox with two or three inputs and a single output.
One of the inputs are the files, the others might be a configuration file or user adjustable parameters like length. The program is well isolated if you can't give it any combination of inputs that doesn't produce a poem or an error message related to the usage of the program.
A badly isolated variant of the same program would be one where the user had to think a lot about the internal behavior of the program, e.g. how file names are handled or where so many parameters of the poem generation have to be supplied as parameters, that the user essentially has to rewrite the core of program with their parameters. Or the user could supply a file that allows them to gain RCE or crash the program.
The author talks about complexity like it's always an intrinsic thing out there (essential) and the job of the abstraction is to deal with it. It misses the point that a great deal of the complexity on our plates are created by abstractions themselves (accidental). Not only that, sometimes great abstractions are precisely the ones that decide to not isolate some complexity and allow the user to be a 'power user'.
I agree with this. Sometimes abstractions are the wrong ones. In a layered system, where each layer completely hides the layer below, sometimes abstraction inversion (https://en.wikipedia.org/wiki/Abstraction_inversion) occurs where the right mechanism is at the bottom layer but intermediate layers hide it and make it inaccessible, leading to a crappy re-implementation that is slower and usually less capable.
1. how to specify memory layout for faster execution
2. how to give hint when I press . in IDEs
if you use typing outside these two scopes you'd probably find many troubles.
- encoding invariants and define valid evolutions of the codebase
- memory safety without a garbage collector (see Rust’s Affine type system)
That’s what I use types mostly for. I don’t care about compiler hints, well structured code with sane naming conventions solves that problem without the need for types. But I do want my program to fail to compile (or in JIT-land, fail unit tests / CICD) when I do something stupid with a variable.
The former is about typing speed and I already type faster than I think. The latter is about guardrails protecting me from my own human error. And that is a far more realistic problem than my IDE performance.
Of course, it's on you to make that happen - if you have a Between6And10 type and you implement as struct with an int that someone comes and writes 15 into it, it's bad news for your assumptions.
If you can make it compile time safe, then great, but even when you can't, if you know the invariants are holding, it's still something powerful you can reason about.
Whether you use that meaning to produce IDE hints (say, via Python type annotations, though I am aware Python typing isn't only that), or you feed it to a compiler that promises that it will ruthlessly statically enforce the invariants you set via the types, or anything else, is up to you, your goal and the language you use.
For isn't, the return type STM () doesn't give you anything back, but it declares that the method is suitable for transactions (i.e. will change state, but can be rolled back automatically)