People who are only used to dynamic languages may feel like a static language’s type checker is an overbearing teacher (from the 1950s) standing over your shoulder, just waiting to jump on you for every silly mistake! While that feeling is common and valid as you’re learning the language, when you become fluent you see the compiler as more of an ally. You can call on it with a keystroke and it will find many problems that would otherwise only happen at runtime, possibly months or even years in the future!
Moreover, in more advanced languages (such as dependent typed languages), the compiler can actually infer the body of your functions which leads to a totally different style of programming that feels like a snippet plugin on steroids.
https://www.unisonweb.org/docs/refactoring
In Unison you can implement a change and propagate it just far enough to run one little experiment. There’s no need to upgrade all your code at once.
I've written big applications in python, every time I made a significant architectural change (which might not even be huge code-wise, just far-reaching) I feel super uncomfortable. I know that I have to follow up with an intense testing session to make sure that I go through all code paths and everything still works correctly. Even then sometimes the testing is not thorough enough and you end up with a regression in production.
As a result I tend to avoid these types of changes as much as possible, even I believe that the application would benefit from them.
Meanwhile in Rust it's a breeze. Yesterday I decided to make a small but far-reaching change in an emulator I'm writing: instead of signaling interrupts by returning booleans, I'd return an "IrqState" enum with a Triggered and an Idle variants. It's more explicit that way, and I can mark the enum "must_use" so that the compiler generates a warning if some code forgets to check for an interrupt.
It's like 4 lines of code but it means making small changes all over the codebase since interrupts can be triggered in many situations. In Python that'd be a pain, in Rust I can just make the change in one place and then follow the breadcrumbs of the compiler's error messages. Once the code builds without warnings I'm fairly certain that my code is sound and I can move on after only very superficial testing.
I’m sympathetic to the idea that you want to solve the immediate problem first rather than have to move the entire codebase through several iterations. I’d be interested how well Unison handles that on a complex codebase in reality though.
Why I would want to go to dynamic typing is beyond me. My performance/efficiency would greatly decrease. .
The next change I will implement will likely be to not allow null in a property, which can be done in c# 8
Dynamic typing won't help with that.
One of those is worse.
Also, some statically-typed languages (like Haskell) allow you to defer type errors to runtime if you really want.
Ideally, you want a language that starts out completely untyped, efficient for that initial “sketching in code” phase. Then, as your program takes shape, it can start running type inference with warnings only, to flag obvious bug/coverage issues without being overly nitpicky. Then finally, as you refine your implementation and type-annotate your public APIs (which does double-duty in documenting them too), it can be switched to reporting type issues as errors.
Unfortunately, a lot of typed languages behave like martinets, and absolutely refuse to permit any such flexibility, dragging the entire process of development down their level of pedantry and generally making early development an almighty ballache. Conversely, untyped languages provide sod all assistance when tightening up implementation correctness and sharpening performance during late-stage development; it’s pure pants-down programming right to the bitter end.
Mind you, I think this says a lot more about programmers (particularly ones who make programming languages) than it does about languages themselves.
--
† (Unless, like, you’re Donald Knuth.)
Additionally, it is fairly typical in js to use functional programming styles, making type inference a breeze / provable / not introducing much boilerplate by adding types.
For example, in Haskell: https://downloads.haskell.org/~ghc/latest/docs/html/users_gu...
On the top of my head:
* Dart (just don't give a type and the variable is dynamic)
* Groovy (add @CompileStatic or @TypeChecked when you're ready)
* Racket (start using typed-racket)
Also, none of them are in the mainstream, which creates general challenges to adoption. Fortune favors the entrenched.
...
Another thing worth considering IMO is structural rather than nominal typing, which fits more with the “if a value looks right, it is” philosophy that untyped language aficionados enjoy without going for full-on pants-down duck-typing. ML favors this, IIRC (I really must learn it sometime).
I find nominal typing can get a bit excessively OCD, demanding so much additional annotation as to obscure the code as much as clarify (“Hi, Swift stdlib!”).
I also wouldn’t mind being able to define sets of permitted type mappings in advance, and letting the compiler figure out where in the code to insert (implicit) casts. For instance, if I’m passing a variable typed as integer where a float/string is expected, it really wouldn’t kill it to promote it automatically; most are perfectly happy to promote integer literals where a float is required after all. (And if I do want to know about it, which I usually don’t, I can always turn warnings/errors on.)
std::vector<Pair<std::string, MyInt>> s;
s.push_back(Pair<std::string, MyInt>("a", MyInt(1)));
s.push_back(Pair<std::string, MyInt>("b", MyInt(2)));
s.push_back(Pair<std::string, MyInt>("c", MyInt(3)));
Gross. I'm being punished.Look at this Rust code:
let mut s = Vec::new();
s.push(Pair::new("a", 1));
s.push(Pair::new("b", 2));
s.push(Pair::new("c", 3));
Both are statically typed, but one uses inference. It practically feels like Python everywhere except where type declarations matter most to me -- function signatures: fn foo(x: f64, y: f64) -> Point {...}
In Python, those aren't very helpful. def foo(x, y): ...
:c std::vector<std::pair<std::string, int>> s;
s.push_back({"a", 1});
s.push_back({"b", 2});
s.push_back({"c", 3});
I mean let's compare apples to apples here.That IS better than Rust since it migrates the type from n container members to just the 1 container. It doesn't seem possible to replicate that in Rust[0].
My Rust evangelism IS hurt by this. But its HP bar is big[1].
0: https://news.ycombinator.com/item?id=10891892
1: https://www.reddit.com/r/rust/comments/bya8k6/programming_wi...
Also, being a C, you still have to declare* all the types, even when only `id`, which is tedious AF. Also-also being a C (which doesn’t so much have a type system as a loose set of compiler directives for allocating memory on the stack), it’s not as if declaring exact types does much to improve program correctness. You can still stick any old object into an `NSString*` at runtime; it just dumps stack when you try to send it an NSString-specific message is all.
Oh, and one more: the ObjC compiler can have trouble recognizing selectors (method calls) when the receiver’s type is `id`. Being a C, obviously you have to import all the headers just to use them at all, but if in searching those headers it finds two method definitions with the same name but different parameters then its superficially Smalltalk facade quickly breaks down. (I’ve run into this, it’s very annoying.)
The current dynamic language ground is riddled with linters and additional tooling that static languages have solved already. This costs developer time (god, the amount of time people spend on writing an eslint config and selling it.)
Statically typed languages will confront you with a lot of obscure errors and just generally get in the way.
Eventually the good developers will eventually learn the language and get burnt by float32 / sql injection / xss / dates / not validating input and they'll learn enough to become extremely productive.
Static languages have evolved a lot; now the standard error messages are far better and the tooling is amazing. At the same time a lot of dynamic features - usually reflection based - have come along and provided their own breed of arcane and nutty rules and errors which seem designed to catch out only the strongest developers (the ones which break SOLID are the best!).
OTOH, a good [weak] untyped language should accept a float where an int is expected as long as it has no fractional part (otherwise it should throw a runtime type error, not implicitly round). Depending on how weak the language is, it could also accept a string value, as long as it can be reliably parsed into an int (this assumes a canonical representation, e.g. "1.0"; obviously localized representations demand explicit parsing, e.g. "12,000,000.00").
A good rule of thumb, IMO, is: “If a value looks right, it generally† is.” This is particularly true in end-user languages where conceptual overheads need kept to a bare minimum. (Non-programmers have enough trouble with the “value” vs “variable” distinction without piling it with type concepts too.)
--
† Ideally this qualifier wouldn’t be needed, but there may be awkward corner cases where a simpler data type can be promoted to a more complex one without affecting its literal representation. That raises the problem of how to treat the latter when passed to an API that expects the former: throw a type error or break the “no lossy conversion” rule? There’s no easy answer to this. (Personally, I treat it as a type error, taking care in the error message to state the value’s expected vs actual type in addition to displaying the value itself.)
Predicate assertions instrumented to run during development catch an equivalent amount and more, since they can evaluate data at run-time.
Dynamic types combined with argument destructuring allows for very loosely coupled modules. I can see it being similar to row polymorphism, but then you have to ask whether it's worth the extra time? In many business apps a significant portion of LoC is mapping JSON/XML to DTOs to Entities to SQL and back. If everything starts and ends with JSON coming from an unverified source, forcing it into a "safe space" statically typed business program is almost ignoring the forest for the trees, possibly even giving a false sense of security. It's (over) optimising one segment of the system; it's not necessarily a waste but it's probably time which can be better spent elsewhere.
In fact, proponents of static typing would argue that the types make your code easier to refactor later, because you will be aware of all the usages, and able to move things around with confidence.
The drawback is that you need to make the entire codebase correct (or commented out) every time, not just some isolated piece you're experimenting on.
I agree with the GP‘s point that static typing forces you to do that kind of design work earlier.
(Edit: You raise a good point, though. I think a lot of people run into this kind of problem with static typing.)
The problem? Every one of your 20 levels of functions/subroutines has an unnamed grab bag of variables. You get to keep that in your head. If you want to know if you have a value in a given branch of code, your best bet, aside from reading the code of the entire callstack, is to dump @_ in a print statement and run the entire program and get it to call the function you are in. Oh, and if one of those values contains a few screens worth of data, you will need to filter that out manually. Even "documentation" in the form of comments or tests will be unreliable due to comment-rot or mocked test assumptions.
Even in Python, I'll often have to go up the callstack to know what a named parameter actually is. And if similar shenanigans are going on, I again have to pull out a debugger or print statements to know what I can do with an argument.
With a static type, I see plain as the text on my screen what type I have as a parameter and I immediately know what I can do with it. As weak as Go's type system is, it is worlds better than Perl and Python for maintaining and creating large, non-trivial codebases. The price is passing it around at the time of writing.
If you only have to write the type once at the top of the 10 layer, you don't have to do much to change it.
However, that's pretty much the textbook example for designing with dependency injection in mind. Static typing won't fix a terrible architecture.
The original description sounded like 13 functions being manually chained together, each inside the last. Where to go from there is very situational.
Using a POC is pretty much a given.
For transformation in the simplest form, I'd typically have a series of extension methods (or some equivalent, like a transformation class which only has methods and holds a reference to the object) which can be called to transform and return the object or return a modified copy of the object. That's easier to test and debug as each method doesn't need to know about the one above/below it (they get called in parallel). You can also easily modify the order of the transformation, or cut out parts of it. When testing/debugging, you just initialize data to some state, then test the broken piece of the pipeline.
If the 13 vertical calls are for initialization, DI allows you to turn that into 0 calls. You probably need reflection in your language to make DI work though. I've never liked DI frameworks built by others, and have generally made my own.
All that said, I'd stand by my point that if 1 item is passed vertically through 13 functions before it gets used, then there's probably some way to either make things a bit more horizontal, or to automate the passing.
Another strong belief: If DI or some other "good" design tool results in more lines of code or significantly more confusing code, then either it's being done wrong, or it isn't applicable to the situation. There are generally more wrong places to use a tool or pattern than there are right places to use them. A power drill is a terrible hammer.
It's basically a fancy way of saying 'Use reflection to read all of the dependencies that exist in my code, and then automatically initialize those dependencies and pass them to the classes which need them.' This (for me) is a recursive process which starts at root "singleton" objects (single per DI session), then moves down through the tree that is my code and its dependencies.
Typically, dependencies are passed via constructor then stored in a field. I personally think that's a dumb way to do it, as I'm using DI to eliminate boilerplate code, and you don't often get code more boilerplate and tedious than copying a variable into a field. I instead mark fields with obvious attributes that show that DI is used for initialization, then have my DI framework automatically fill them in.
People often overuse interfaces with DI, which leads to very difficult to trace code. I consider that an anti-pattern unless writing a library for others. Unfortunately, I think the people doing this have made DI seem like a trade off: you remove boilerplate code, but you lose clarity. You'll see this in virtually every DI tutorial, which is unfortunate. DI can be done just as well with concrete objects.
If you're using a language with reflection, I'd highly recommend learning a DI framework. Once you've figured out what you don't like you can find a framework that works the way you prefer, or in the worst case write your own. You definitely have to structure your code for DI, and it's difficult to add after the fact. That said, I was able to chop 5 to 10k lines off a 100k project I converted last year, and more importantly adding new functionality became much faster and easier. Again YMMV.
Adding a double and boolean field to a Context object, I don't have to touch anything at the intermediary API layers. Just as with your tuple/dict.
As far as static types are feasible they are great to have. Clojure's spec is the gold standard I work to; if anyone has a better system it needs a lot more publicity.
But this is exactly the GP's argument, no? According to the agile philosophy (and my personal experience), we don't have enough information at the start of the project to make right decisions about all parts of the implementation - so we try to decide about just the most crucial architectural aspects and make the rest as easy to change at possible, learning as we go.
From this perspective, the argument is that dynamic languages make it easier to postpone some decisions and allow us to quickly experiment with the things we've left flexible. And then, over time, once we're more confident in our decisions, we can put in the effort to repay this technical debt and lock things down to some desired level.
that is exactly why I like static typing - I can just change stuff and ask the compiler to kindly list all the places that need to be updated to accomodate for the change.