Runtime metaprogramming has been the raison dêtre of Ruby since Rails first took off. And, yes, you can do really cool incredibly powerful stuff with it. Likewise any Lisp with their various macro systems. CLOS in Common Lisp can also do very powerful cross-cutting metaprogramming stuff.
The key problem has been that the resulting programs are fantastically hard to understand. One of the things I think Dijkstra got right in "Goto Considered Harmful" is that humans can reason about programs better when there's a strong correspondence between the textual structure of the code and its execution structure at runtime.
Runtime metaprogramming deliberately upends that.
I think it's probably totally fine to use those kinds of features as debugging aids when hacking on a program and figuring out where stuff is going wrong. That aligns with many of the use cases Hillel lists here. But you probably don't want to commit code like that to the repo and require everyone else maintaining the program to understand it.
For better or worse, we seem to lean strongly towards languages and language features that are used not just during the development process but also make sense in committed, maintained code.
Dynamically typed languages lean in the other direction. They can be very powerful and productive while you're in the middle of working on a program, but the resulting artifact is harder for others to understand and maintain.
Dynamically typed languages to me feel like unbaked plasticine. You can have a lot of fun molding it and you can get it into the shape you want much quicker than you could carving wood or stone. But if a dozen people are collaborating on a sculpture over months or years, it's impossible to build on top of someone else's plasticine part without it shushing down and falling apart.
Or course, the holy grail is baked plasticine: a language that is dynamically typed and moldable while you're figuring stuff out and then can be baked with static types for the parts that are solid and need to be reliable. I've yet to see an optionally or gradually typed language pull that off well. The boundary between them just gets too weird.
> The key problem has been that the resulting programs are fantastically hard to understand. One of the things I think Dijkstra got right in "Goto Considered Harmful" is that humans can reason about programs better when there's a strong correspondence between the textual structure of the code and its execution structure at runtime.
When I briefly worked on a Ruby on Rails app in 2015, my first ticket was to investigate a crash that had been reported. I quickly reproduced the issue and got a stacktrace that ended in a function call to "is_readable", so I said: "No problem, I'm just going to grep for that identifier to find the definition." Cue two hours of utter frustration until I learn that you can use `.source_location` on a function to get a file and line where it is defined, which pointed me to a pile of metaprogramming held together by strings and class_eval. To no one's surprise, I fully support the statement quoted above.
Yes, look at literally any complex Python library and there's generally going to be an unreadable mess of dunderscore properties and ClassFactory stuff. I've seen Python libraries that call a SQL database query and then build Python objects and code from that! What a nightmare.
Many years ago I worked on a Rails project where the two previous developers were particularly creative with metaprogramming. That cost a lot of time and extra money to my customer because it was really hard to figure out all the tricks they used. Instead I write Rails projects in the simplest possible way I can figure out. It works and everybody else and I will be able to understand it quickly next year. If it makes me look dull, I don't care.
In the 80s there was an April Fools issue of "Communications of the ACM" positing a "COME FROM" statement (not too long after "GOTO Considered Harmful"):
It was a joke because of course it was obviously worse than GOTO. But much of modern programming seems to consist precisely of COME FROM style constructs. A common term is "inversion of control", which can also mean "You don't have control".
As with metaprogramming, it's powerful in that it can take control of your system without your direction, but dangerous in that it's hard to predict when it will. I find that the hardest thing to debug is when things don't happen, when it's out of your control to make them happen. There's no place to put a debugger on "never happened".
Dynamic types, similarly, are hard to debug because they fail by not happening. Errors aren't caught, but there's no instant at which it wasn't caught because it was "any time".
> In the 80s there was an April Fools issue of "Communications of the ACM" positing a "COME FROM" statement (not too long after "GOTO Considered Harmful"):
And to make you feel really old, "Go To Statement Considered Harmful" dates back to 1968. The context was the debate on structured programming which mostly happened in the 60s and early 70s (though it has flareups unending, they tend to be limited in scope).
I actually discovered it from INTERCAL, which I know to be from the 70s, but the wiki told me COME FROM was added by INTERCAL-C which was created in the early 90s, so it’s a much later addition to the COME FROM universe.
> The key problem has been that the resulting programs are fantastically hard to understand. One of the things I think Dijkstra got right in "Goto Considered Harmful" is that humans can reason about programs better when there's a strong correspondence between the textual structure of the code and its execution structure at runtime.
This resonates very strongly with me. I've been doing a whole bunch of side/toy projects in Rust for months, and I can attest that even compile-time metaprogramming can make programs "fantastically hard to understand". Runtime metaprogramming makes that problem an order of magnitude worse.
Don't get me wrong, I'm not against metaprogramming, either compile-time or runtime. But it's way too easy to get overly enthusiastic when doing it.
Honestly, I think runtime metaprogramming can be a lot easier to understand then compile-time metaprogramming, because it's easier to build runtime debugging features for runtime metaprogramming, compared to building runtime debugging features for metaprogramming that's already been erased at compile-time. For example, a sibling comment mentions Ruby—well, pry already supports "show-source [expr]" for basically any possible Ruby expression you might want to call, no matter how meta-programmed it might be. Navigating and debugging Ruby code can get very fluent once you have the right set of tools.
Compile metaprogramming can have compile-time debuggers. If it uses the same language for meta and regular, it should be the same debugger, ideally.
The IDEs haven't quite embraced this concept properly yet, though: they usually have distinct "editing", and "running" states, but what's really needed is "editing", "building", and "running", with debugging available in the latter two.
Completely agree. I spent a lot of time thinking about what programming language features are important for understandable code when I was designing Darklang. One of the major negative influences was from Rails, where I had often found it hard just to understand where a particular piece of code came from. Clojure is similar - with "why is there a nil here" being the challenging question. With Python, I made my own mess making things far too polymorphic.
But I had previously had experience with OO languages - Java and C++, and they often had similar challenges, though not quite as pronounced, along with problems of their own.
Eventually, I came to realize statically typed functional languages were the panacea. Sum types offered better composition than OO, and they had a good trade off between power/succintness (coming from higher order functions) and program understanding. In particular, the problems from other languages: what code is this, what type is this, where did this nil come from, were solved.
Usually though, macros in lispy languages are evaluated not at runtime, but before that, at read time or macro expansion time.
There are perfectly valid use cases for these things, which do not make it harder to understand the code. Properly done, they might even make the code easier to understand. One example is writing a "timing function" (a macro). WHy a macro? Well, because you cannot call (time expr ...) when time is a normal function, because of order of evaluation. You could put the expressions into lambda and reduce readability that way, or you could make time a macro and get rid of the lambda wrapping of the expressions.
What if we want or need code that has to run for centuries? (Just a thought experiment.) What struck me in your post was your mentioning the fact about debugging being difficult. What if we don't debug? What if we treat them as evolving systems. You can debug mechanisms, but you can mainly treat organisms. Possibly this is the actual line between mechanical and biological systems, and that a meta-programming language (the DNA) is involved is hardly surprsing.
Sort of related, I always wanted to ask someone who only have extensive experience with dynamic languages - what is your mental model when you write a parameter, variable? Don’t you inherently link some vague type to each variable? I honestly can’t think back to when I started programming (even though my first lang was php).
TypeScript is definitely the most successful (in terms of popularity and in terms of I think they did a good job) language to try to tackle this.
But it took possibly the best living language designer on Earth to do it, the type system is (deliberately) unsound and will never be able to be used for optimization, and the type system is just incredibly complex. Literally a Turing-complete language running in the compiler.
And that's the least bad that anyone's been able to come up with. I applaud the TypeScript folks for doing it, but I think it's a worse user experience than most other modern statically typed languages unless you happen to be sitting on a pile of JS code already.
It's like plasticine with wires through it. Sure, the wires help but it would have been better to not start with plasticine in the first place.
But the thing that really drives me nuts about this never-ending debate is that static and dynamic are not a dichotomy. You don't have to choose one or the other. You can have both.
The real choices are:
1. Do you want the language to require the coder to provide type information (e.g. Java), or permit the coder to provide type information (e.g. Common Lisp) or forbid the coder from providing type information (e.g. early versions of Python, Javascript).
2. How much work do you want the compiler to do with regards to finding potential problems at compile-time, and what do you want it to do when it finds one? Options include: do nothing (e.g. Javascript), warn if it finds a problem (e.g. C), or forbid you to run the program if it finds one (Java, Haskell).
Personally these choices have always seemed like no-brainers to me: allow but don't require type information, and warn when the compiler finds a problem but allow the program to run anyway. What possible advantage is there to be gained from any other choice?
> What possible advantage is there to be gained from any other choice?
You're thinking like a single individual. Almost all programming that happens in the real world is in teams, and most of that probably within companies.
Teams and especially companies often benefit from strict standards that keep everyone on the same page and make it harder to screw things up.
> Teams and especially companies often benefit from strict standards that keep everyone on the same page and make it harder to screw things up.
Sure, but those standards don't have to be enforced by the language design. They can be enforced, for example, by policy, e.g. "Don't push anything that produces warnings to the master branch". That still allows you to, for example, test parts of an uncompleted program, which can vastly improve productivity. Not being able to test-run a partially completed program can increase your development cycle by orders of magnitude.
The issue with enforcement by policy is it puts the onus of checking things on whoever is enforcing the policy.
Which is usually either no-one, or an already busy/overloaded team lead or (even worse) manager.
Every software engineering organization I’ve been at either wanted, or put in place, automated policy enforcement because otherwise it either never happened (and hence there were constant problems), or everyone hated doing it because it felt like a waste of their time (with it’s own constant source of problems).
it’s best for everyone if a problem is identified and called out as close in time to when it’s created as possible, and as clearly as possible. Ideally before it can be part of any product, or be depended on by other work that would need to be refactored.
Which automated tools can do cheaply. If it is in the compiler, even better!
Doing it manually delays the feedback cycle, AND burns people decision energy, which is almost always the thing everyone is in the shortest supply of anyway.
> it’s best for everyone if a problem is identified and called out as close in time to when it’s created as possible, and as clearly as possible.
Sure. But it does not follow that it is beneficial for your language to forbid you from testing some code you wrote over here until you fix a completely unrelated problem over there, indeed until you fix every single problem that the compiler can find.
> Doing it manually delays the feedback cycle
I'm not talking about "doing it manually", I'm talking about the compiler issuing warnings instead of errors.
If you can check in those problems, and make them problems for everyone else - it absolutely is an issue!
If the policy is ‘you can generate “warnings” all your want locally, but you can’t check them in’ then sure.
The issue of course is folks want to check things in as soon as they ‘work’ in their basic use case, even if it means bypassing a bypassable alert.
So it means enforcing it at check-in time, in a non bypassable way (usually), which just means folks have a lot of ‘hidden’ cleanup to do before submitting, while they have their code ‘working’, which is always a drag.
Such policies should be defined by the company, not enforced by the language.
Even in static languages world, a lot of projects allow dev/nightly builds to be unstable. Which is literally what you're talking about: checking in problems for everyone else.
But checking in policy is a VCS/CI domain, not a language domain.
CI/CD, based on CTO edict? Have them (or their startup equivalent) make the edict at the start of the company's lifecycle for best results.
Don't make it a human procedure, make it a "you're physically unable to deploy until tool X doesn't throw a warning". Anything else will, as you say, fail.
> Sure, but those standards don't have to be enforced by the language design. They can be enforced, for example, by policy
Yes, policy debates are amazing, so fun, so productive. And nothing funnier than having to discover completely new and incompatible policy when changing company, or trying to change policy.
That's one of the few good things I have to say about Go, they considered policy debates and went "fuck that". I think they went too far and wrong in lots of ways, but on that front I can't fault the heart, even if I hate the language.
> Yes, policy debates are amazing, so fun, so productive.
Who says there's going to be a debate? The commit hooks and/or CI pipeline should prevent you from merging code that doesn't pass typecheck. It's still enforced by tooling, just at a different place in the process.
Well, you're going to want those checks regardless because, if we really want to be pessimistic, developers can commit code without running the compiler locally anyway.
The question is really, before you commit/push your code, and you just want to run the code, do you really need to be prevented from running the code just because it didn't pass typecheck? I'm inclined to say no.
It’s not being pessimistic, it happens all the time, which is why CI/CD is a thing. It’s also why if it’s a compiled language, I’d personally want to know if it’s definitely not going to be working if I go to check it in right away.
If I wanted something that could kinda work, I’d just use a scripting language?
This is silly. There are tools that enforce the types of policy we're talking here very well.
In a TypeScript codebase you would not let CI pass if the TypeScript build fails. Locally you can still run the output. There's not much room for debate and I've never seen a TypeScript project that both has CI and allowed failing builds to be merged. I've also never heard of someone proposing that PRs failing to build should be allowed to be merged.
Not every team has eternal policy debates. Eternal debates indicate a deeper problem with the engineering culture. I'd rather have that problem become visible so that I could address it, than keep it hidden under a pile of irreversible decisions.
> They can be enforced, for example, by policy, e.g. "Don't push anything that produces warnings to the master branch".
I laughed out loud when I read this.
Funny thing is, this would work where I'm at now, Google, because Google actually has an enormous number of presubmit checks that require all KINDS of things out of your code, it's practically crazy how many checks it runs. Since there's already so much setup for these checks, yeah adding one more would be fine (and it already does this for type annotations for dynamically typed languages like Javascript).
But most places aren't Google, and it's way more realistic to prevent this kind of problem by just having a language that'll enforce it automatically, then you never have to think about it, or have a policy debate, or always make sure during code review to look for that thing, etc.
If I'm putting out some small open source library where I might get an occasional pull request, I'd much rather have the language automatically enforce correct typing than have to remember to look for it myself every time (and then possibly miss something if I screw up).
Well, sure, they don't have to be. As a team you can come to an agreement on policies, and if you have the time, you can set up linting or configure your tools to make sure that they are enforced in CI. We use Rust and TypeScript at work, and for the latter we've made some effort to make it as strict as possible, and even added some eslint rules to enforce things more. But it sure is nice when the language itself does that by default for you, so you don't have to advocate for lint rules or tsconfig options and spend time setting them up. Plus, if it's built into the language, it effectively means that even people outside your organization are following the same policies as you.
All that being said, your point is a valid one. It's a tradeoff and depending on what you're used to, you'll feel the pain from one approach or the other more strongly.
Could be right. Make a change in one place to see how it behaves, code elsewhere that wouldn't be executed for your test input now doesn't compile - do you get to learn from that experiment now, or do you have to go and placate the compiler temporarily first.
For example, making unused function arguments a fatal error would be an easy place to increase iteration time.
Initially I disagreed with you, but I do see value in what you propose, but I would implement it the other way around — add an option to the compiler that turns type check errors into warnings only, say dev mode.
Then the following arguments are moot, as CICD will run it in the “sane default” setting.
> and warn when the compiler finds a problem but allow the program to run anyway
Your compiler giving you a type check error is saying that the program is meaningless. What is the advantage of running a program that is known to be wrong?
The design of the type system will define what is meaningful/meaningless per type check rule (eg a missing branch which can be a warning vs an ambiguous type inference that means the program cannot be compiled). The question is really whether you want to design a type system that can be checked statically, which to me the no-brainer answer is "yes."
I don't want to have to run the program to know if it type checks. I don't want to have to run it to know what types are in the program, or the signature of functions. I want my tool to tell me when my code is provably wrong, and to prevent provably wrong code from being merged into a codebase without executing it.
> Your compiler giving you a type check error is saying that the program is meaningless.
The TypeScript compiler will absolutely give you errors for well-formed programs that are guaranteed* do the right thing with respect to what the author intended and that anyone else can see is correct but that the TypeScript compiler itself doesn't reason about correctly. In instances where this happens, it's very useful to be able to ignore them—just like you might ignore the protests of a misinformed team member who insists that such-and-such won't work, even though it provably will. (Although the alternative of just not using the TypeScript compiler can be a worthwhile decision as well.)
* not to be confused with undefined behavior in C where it might just happen to work with one known compiler/platform combo but break horribly for other valid targets
The cost for this is that programs with type errors must still be specified to have a well-defined semantics. That in turn means that you can't hang useful language features off static types.
For example, TypeScript explicitly declares as a design goal that they don't use the type system to generate different code:
In Dart, doubles and integers are different types with different representations. The above line of code works because if we see an integer literal in a context where a floating-point number is expected, we implicitly treat it as a double literal. Note that there's no runtime conversion here. We can do this because Dart does have a sound type system and is happy to use it for language features.
That's also why Dart (and other statically typed languages) support extension methods while TypeScript does not:
If the language gives users the ability to type their code, it's really nice to be able to give them as much value in return for their effort. That means building language features on top of them and using types to generate smaller, faster code.
But languages with unsound type systems or that treat programs with type errors as well formed can't really do that.
> Your compiler giving you a type check error is saying that the program is meaningless. What is the advantage of running a program that is known to be wrong?
Well one bit is you might not be looking at the meaningless part.
Sometimes when doing large refactorings or updates never getting to run, try, or look at anything is discouraging. It's a great feeling when it compiles and everything works (or near enough), but spending two days cycling between compiling and fixing compilation errors is not as enjoyable as e.g. seeing the unit test report's green bar tick up.
Maybe compilers should provide more enjoyable feedback, not just better error messages (though I fully support that movement).
I'm not sure I get what you mean by "you might not be looking at the meaningless part"?
What's the difference between seeing a bunch of test failures and fixing them one by one until the test suite is all green, and seeing a bunch of compiler errors and fixing them one by one until the compiler runs clean?
> I'm not sure I get what you mean by "you might not be looking at the meaningless part"?
For example, you might not be pushing buttons that trigger the meaningless part, but the other parts of the system might still be functional.
> What's the difference between seeing a bunch of test failures and fixing them one by one until the test suite is all green, and seeing a bunch of compiler errors and fixing them one by one until the compiler runs clean?
The difference is in one of the cases a highly qualified professional has freedom to decide _when_ to do the fixes.
Large refactoring and updates are exactly where strong type systems are the most useful. Make change, fail build, fix build, be happy. Unit tests are still there to turn green when you're done.
I can't think of many worse experiences than my past self trying to refactor Python 2 code. Compared to _any_ type system it's just not even close.
I agree. Sometimes I'm prototyping something or feeling my way down one possible implementation -- it's a lot more efficient to go deep in one area of the codebase and come back to fix up everywhere else later once I'm sure this is the right trajectory. In my day job, I use Flow.js which gives me this and I'm a little hesitant to give that up, given there's no many static type checkers that work this way.
I get the sentiment, but ime this speaks more to architecture and code organization than the merits of static typing. It's always possible to skip type checking by designing the software to be modular enough that unreachable code isn't included in the local build.
Like updating data structures should never result in days of work before you can run simple tests. Create a new build artifact that only pulls in the new data structures and the software modules you're working on (and their tests). If you need to pull in the universe to run any unit tests, they aren't unit tests.
> What possible advantage is there to be gained from any other choice?
Substantially better performance, which still matters in some domains.
If you occupy any space on the spectrum besides fully static typing, you must represent and check your types at runtime. This means that every object (even Int and Bool!) needs to have a header that tells the runtime what type it is, which can quite literally double your RAM requirements.
If a language is already including object headers anyway (like Java does), you're correct that there's not an enormous barrier to embracing gradual typing [0]. But there are a lot of places where we really do still care about RAM usage, and erasing types at runtime is an obvious place to free up a lot of RAM very quickly.
[0] Indeed, you can sort of do this in Java already by just typing everything as Object and coming back later to correct it with actual types, though please don't actually do this.
> If you occupy any space on the spectrum besides fully static typing, you must represent and check your types at runtime.
If the language requires you to give at so much type information that the compiler can infer all the types, I would say that language, from the compiler’s view, would have fully static typing, so it wouldn’t have to do any checks at runtime, but from the programmer’s viewpoint, it could look as if it didn’t have fully static typing.
The modern auto to deduce a type in C++ makes a small step towards that, but I think it could be taken further. The language could deduce types both in the direction “if you pass foo to a function taking an integer, it must be an integer” and “if you only pass integers to bar, bar must take integers”.
I expect it will be hard to specify a powerful algorithm for filling in the blanks that feels natural to programmers, though. If the compiler frequently complains about missing type information where the programmer doesn’t understand why it complains, the language won’t be popular.
> I expect it will be hard to specify a powerful algorithm for filling in the blanks that feels natural to programmers, though. If the compiler frequently complains about missing type information where the programmer doesn’t understand why it complains, the language won’t be popular.
This is the problem in a nutshell. Folks in the optional typing and gradual typing communities have been trying to pull this off for decades and so far no one has been able to come up with a language that:
1. Lets you ignore types completely and write dynamically typed code in some parts of the program.
2. Gives you the expected performance of completely typed code in parts of the program that are typed.
3. Is sufficiently pleasant to use that it gets adoption.
There are languages that give you either 1 or 2 along with 3. There may be some language out there that gives you 1 and 2 but if so, it doesn't give you 3 or I would have heard of it.
What you're describing is already common in functional languages such as OCaml and Haskell. They tend to use the Hindley-Milner type system, which allows for exactly the kind of non-local inference you're talking about (where `auto` is only for local inference): https://en.wikipedia.org/wiki/Hindley%E2%80%93Milner_type_sy...
However, better type inference does not mean that a language is no longer statically typed, it just moves a lot of the burden of assigning types from the programmer to the compiler. You still don't have as much flexibility at runtime, and you still can't run your code until the compiler can prove that it it correctly typed. The only thing you get is less syntax, not dynamic semantics.
> 1. Do you want the language to require the coder to provide type information (e.g. Java), or permit the coder to provide type information (e.g. Common Lisp) or forbid the coder from providing type information (e.g. early versions of Python, Javascript).
Liberties are rarely created and destroyed. Instead, they tend to be trasferred from one place to another. So an equally valid way to phrase this question is from the compiler's perspective:
1. Do you want the compiler to be able to rely on the presence of type information (e.g. Java), or sometimes have access to type information (e.g. Common Lisp) or be forbidden from using type information (e.g. early versions of Python, Javascript).
In that equivalent formulation, the value proposition of static types is clearer: When the language specifies that everything has a computable static type, then the compiler can rely on types for useful language features (extension methods, overloading, static dispatch, etc.) and to generate smaller, faster code.
Dynamic types give the programmer some liberties (they can not worry about types) but takes some others away (they can't reach certain performance goals or use type-dependent features). Static types do the opposite. Pick your poison.
Adding to this, there's a set of language features which use the type system to generate code. So, things like traits in Rust or Typeclasses in Haskell use the type system to infer which instance is being used and insert a dictionary of trait method implementations as the first argument to your polymorphic function. You can't really do this kind of thing if the compiler can't do `Type -> code` and is only verifying the types are consistent.
A pseudocode example is something like:
trait ToString for T {
fn toString(self: T) -> String;
}
impl ToString for number {
fn toString(self: number) -> String {
// implementation here
}
}
impl ToString for Point {
fn toString(self: Point) -> String {
return f"({self.x.toString()}, {self.y.toString()})";
}
}
fn printString<T: ToString>(t: T) {
print(t.toString());
}
Then when it generates code, it uses the inferred type information to generate different code:
let p = Point(1.0, 2.0);
printString(p);
// translated into
printString(implToStringForPoint, p);
let n = 2.0;
printString(n);
// translated into
printString(implToStringForNumber, n);
And then type inference becomes implementation inference, which is really powerful
Virtual dispatch makes it much harder for an optimizing compiler to inline method calls, so there is a significant performance hit even for callsites that do end up always calling the same method.
OP also glossed over the important distinction between opt-in static types (TS, Python etc) and opt-in lack of them ("dynamic" in C#). When talking about what's permitted, these are functionally the same, but the difference has an immense effect on the actual idiomatic use.
I would argue that something like "dynamic" is preferable in a sense that it gives you an escape hatch with minimal syntactic overhead for those cases where dynamicity is really that beneficial, yet defaults to a safer option that's usually the best choice.
Isn’t dynamic’s primary purpose is multiple dispatch methods? In an OOP language (not caring about primitives and non-objects), you can go “dynamic” typed by using Object, and casts.
"dynamic" literally behaves as if the variable declared such had the actual type of the object at runtime every time that it's referenced, and under the hood it is all implemented using casts and reflection (and a lot of caching to make it fast enough - that's the DLR bit). So method calls are still single-receiver, it just makes them behave as if all methods were virtual.
I guess the part where it will also pick the correct overload at runtime depending on the actual type of the arguments is a bit like multiple dispatch, especially if the argument itself is "dynamic". I'd say that the difference is that the list of possible candidates is closed once the class is defined, so even if you declare a new type, you can't customize the behavior when passing it to an object of an existing class.
> What possible advantage is there to be gained from any other choice?
If you're the only one writing the entire stack of code that gets bundled into the delivered program, you're right, no real advantage from other choices.
But as that's rarely the case, there is a lot of advantage from having a forced type system. Otherwise your code might be great but you're pulling in libraries and code from many other teams who chose to omit type info and/or ignore all warnings so you still have the same problem.
> Otherwise your code might be great but you're pulling in libraries and code from many other teams who chose to omit type info and/or ignore all warnings so you still have the same problem.
This is actually a really great argument. Having well-typed dependencies is a real benefit. But I would say you don't need to force typing on people to achieve that. In my experience with gradually typed languages (TypeScript and Erlang) you tend to see decent adoption of typing in prominent libraries. There are natural incentives (i.e. crashing software) that drive people away from depending upon libraries that don't have strong type guarantees.
The existence of this option is what actually finally convinced me to give haskell a real try, but I have to say, I haven't actually ever used it. I alwyas just use undefined to defer any type error, actually seeing each spot there is an error and comprehending how to delay the problem for later basically seems to help me comprehend the problem I need to solve.
In Haskell, type information is used to generate code. And not just in fancy metaprogramming libraries like Servant - any use of chained type class resolution is generating code for you based on static type information. How does that power fit into your framework?
It's not just about type information; in fact, having type information doesn't mean anything in its own right. The point is in static analysis, which can be mandatory (most statically typed languages), or optional (e.g. Python).
I am not aware of any static languages that check the types at runtime (if such languages exist, please mention them in the comments); however. they require a static analysis to happen at compile time, and refuse to compile if the analysis breaks at any point. Python, on the other hand, allows to be compiled and executed without ensuring the type safety first; however it can still be statically analyzed, even without explicit type annotations, using an optional tool such as mypy (which can infer types quite correctly in many cases).
Most languages do some checks at runtime. This is the strong / weak typing dichotomy (which is different from the static/ dynamic one).
For example, Java will throw an exception at runtime if an object is cast (using .asInstanceOf) to an incompatible class. Python throws if you add an integer to a string. Both of those languages are strongly typed.
On the other hand, C has no runtime checks whatsoever. If you add a number to an array the program will happily move the array pointer and maybe overflow, but even that won't cause a runtime failure every time.
This is precisely my point. In the examples above, the checks are implemented in specific cases, and not universally; so the whole point of static typing is the analysis, and not runtime safety.
Eclipse IDE (Java) can run your project, even if it doesn't all compile.
I expect this must be fairly common among IDEs with incremental compilers. You can run valid sections & gradually progress the complete correctness. No need for a dynamic language.
With static typed language, reliability and refactoring are easy.
I'm scripting some small scripts in Python this week but every time I come back to it, I am reminded how not actually very good it is.
There are two reasons you may want to run a program that fails typechecking:
1. The program is in fact correct, but the typechecker can't prove the program is correct and so reports it as a failure (for contrast other typecheckers only report errors if it can prove that there is an error)
2. The type checker is correct in that the program is flawed in some way, but you are the middle of development and it would be more productive if you could observe the behavior of the program before fixing the types. Since your CI pipeline will fail on type errors it's not like you're going to be able to merge your changes before fixing them.
In that case, add a type that constrains the values to the expected user inputs. Now not only are you passing the type checking, you're strongly enforced this invariant at the point where it makes the most sense, you've future-proofed this code against possible future misuse where the expected inputs might change, and you've made the code essentially self-documenting.
I'm not sure many languages support the introduction of types such as:
* An integer that is sum of two primes.
* A string that is created between the hours of 2 pm - 4 pm on a Tuesday.
* A dictionary who's fields comprise a valid tax return.
What do you mean by future proving? If the new version of the code doesn't ship by 2 pm today, the company goes bankrupt and there is no future for the code.
> I'm not sure many languages support the introduction of types such as:
Any language with types can enforce any proposition you can reliably check, which you run as part of the type's constructor. There are multiple ways to do this, for instance in pseudo-ML:
> * An integer that is sum of two primes.
type SumOfPrimes
fn sumOfPrimes : Prime -> Prime -> SumOfPrimes
type Prime
fn primeOfInt : Int -> Maybe Prime
> * A string that is created between the hours of 2 pm - 4 pm on a Tuesday.
type Tuesday2To4String
fn whichString : Clock -> String -> Either String Tuesday2To4String
> * A dictionary who's fields comprise a valid tax return.
> What do you mean by future proving? If the new version of the code doesn't ship by 2 pm today, the company goes bankrupt and there is no future for the code.
Shipping broken code will not save the company from bankruptcy. It's also a myth that dynamically typed languages will get your code out faster.
I'm not sure how you intend to check the creation date of a string in memory since there is no information available to do that or that a tax return is valid given the thousands of rules involved.
Nevertheless let's move on.
It's not a myth, if you are using dynamic typing you will get three times the number of features out the door as someone who uses static typing.
It's just a fact.
1 dynamic typed programmer = 3 static typed programmers in productivity and output.
That is why people use dynamic typing.
From a commerical perspective, static typing does not make sense in most cases. You need something more than a typical business CRUD app to justify it. Usually you only need static typing for performance reasons. Like for a video game or image processing.
In practice, the shipped code doesn't have noticably more bugs. That just something which people who have never used dynamic typing properly like to believe.
If you ever read an article by Eve online where they say Python is their secret weapon or watched what Discord did by implementing everything in Python first. You will understand.
People use dynamic typing less and less, though. JavaScript and Python are the two most prominent dynamically typed languages in the industry, and what do we have there? Most JS developers love TypeScript, and Python bolted on type annotations in the core language (and libraries are generally following suit).
With that in mind, your other claims like the 1:3 ratio really need very solid sources to be taken seriously.
I had a job for 2 years where I had the write the same program in both a dynamically typed language and a statically typed language around every week.
On Monday morning, I wrote the dynamic typed version of the program. Then until Thursday afternoon wrote the statically typed version of the program. Then the rest of the week was writing tests that compare the output of both versions of the program.
After 2 years of doing it, I think I would know. If that sounds weird to you, it's called Prototyping, shocking that someone might prototype something in a scripting language designed for prototyping, I know.
The two classic examples are Eve Online with their "Python is our secret weapon". They used Python to out develop their competition. The other example is Discord who used Python to get to market quickly. Then they used Rust to reduce their operating costs by increasing the code's performance.
Python adding type annotations is an unfortunate problem associated with too many people coming in from statically typed languages. The problem with success and all that.
Statically typed Python codebases tend to be awful because the developers don't realise that they need to break the codebase down into smaller programs.
JavaScript is not a very good language due to the way it was designed and built and pretty much anything is an improvement to it.
> I'm not sure how you intend to check the creation date of a string in memory since there is no information available to do that
See the signature of the constructor function I provided.
> or that a tax return is valid given the thousands of rules involved.
Tax software does it every year. Clearly the government has an effective procedure to decide the validity of a tax return, even if that procedure is executed by a human.
> It's not a myth, if you are using dynamic typing you will get three times the number of features out the door as someone who uses static typing. It's just a fact.
That's just laughably false. There is zero empirical evidence supporting this claim.
> In practice, the shipped code doesn't have noticably more bugs. That just something which people who have never used dynamic typing properly like to believe.
A signature of a function is very different from it's implementation.
"That's just laughably false. There is zero empirical evidence supporting this claim."
There is a reason all the startups are using Python. Just ask Eve Online or Discord. Dynamic typing is great for getting products to market fast.
Neither of them have problems with too many bugs. What you are saying doesn't even make sense, since you would just increase your QA budget if it increased the rate of bugs. So how in earth could the end user see more bugs?
Dynamic typing has an issue but that issue is code performance. Eve Online gets slow downs during massive battles and Discord rewrote parts of their interface in Rust to save on server costs.
Numerous studies demonstrate that static typing had little effect on development time, if the typed language was expressive, but did have noticeable effects on the quality of results and the maintainability of the system. The more advanced and expressive the type, as with Haskell and Scala, the better the outcome, but all studies done so far have flaws.
As for the specific data discussed in that article, here's what that review had to say:
The speaker used data from Github to determine that approximately 2.7% of Python bugs are type errors. Python's TypeError, AttributeError, and NameError were classified as type errors. The speaker rounded 2.7% down to 2% and claimed that 2% of errors were type related. The speaker mentioned that on a commercial codebase he worked with, 1% of errors were type related, but that could be rounded down from anything less than 2%. The speaker mentioned looking at the equivalent errors in Ruby, Clojure, and other dynamic languages, but didn't present any data on those other languages.
This data might be good but it's impossible to tell because there isn't enough information about the methodology. Something this has going for is that the number is in the right ballpark, compared to the made up number we got when compared the bug rate from Code Complete to the number of bugs found by Farrer. Possibly interesting, but thin.
In other words, this "evidence" you cited is a clearly biased anecdote, nothing more. I also recommend reading the HN comments also linked, where former Python programmers describe why scaling dynamic typing to large codebases is problematic.
In the end, my originally stated position on this stands: you have literally no empirical evidence to justify the claims you've made in this thread.
That is the correct end state, but there can be practical considerations for why you might want to run (and possibly even release) the software in this partially correct state, and fix the edge cases later.
I don't disagree, but most statically typed languages don't enforce a level of correctness where this would be an issue. We're not programming with theorem provers with an elaborate specification.
I somewhat agree with your point #2 which is why Haskell provides a deferred type checking mode for prototyping purposes[1]. I think this should be more common among typed languages, but this is not a very typical scenario either.
You presume that the program is able to determine that it is wrong.
One of the most annoying things moving from Ruby / Python into a language with static types that are checked at compile time is dealing with JSON from an external API.
I know, I know. The API could send me back a lone unicode snowman in the body of the response even if the Content-Type is set to JSON. I know that is theoretically possible.
But practically, the endpoint is going to send me back a moustache bracket blob. The "id" element is going to be an integer. The "first name" element is going to be a string.
You encode your assumptions as a structure, then you do your call, ask for the conversion, get notified that that can fail (and you can get an error if you but ask), and then you get your value knowing your assumptions have been confirmed.
It's definitely less "throw at the wall" than just
thing = get(some_url).json()
but it's a lot easier to manipulate, inspect, and debug when it invariably goes to shit because some of your assumptions were wrong (turns out `first_name` can be both missing and null, for different reasons).
For a 5 lines throwaway script the latter is fine, but if it's going to run more than a pair of time or need more than few dozen minutes (to write or run) I've always been happier with the former, the cycle of "edit, make a typo, breaks at runtime, fix that, an other typo, breaks again, run for an hour, blow up because of a typo in the reporting so you get jack shit so you get to run it again".
I haven't had problems with that in strongly-typed languages I've used (most notably, C#). If the response payload isn't JSON, or if it has a missing required field or something is the wrong type, then JsonSerializer.Deserialize(...) will throw an exception. I don't have to add more code just to make the type check pass or anything. (And if I did, _usually_ it's around nullability and the "!" operator would do the trick, or "?? throw new ImpossibleException(...)")
And within the bounds of pragmatism, it's nice that it actually checks this stuff and throws exceptions. In Ruby if the ID field is the wrong type, the exception won't happen at time of JSON.parse(...) but instead much later when you go to use the parsed result. Probably leading to more confusion, and definitely making it harder to find that it was specifically an issue with the JSON and not with your code.
"One of the most annoying things moving from Ruby / Python into a language with static types that are checked at compile time is dealing with JSON from an external API."
That doesn't make much sense - either the API has a well-defined schema, and you define your static types to match (which there are usually tools to do for you automatically, based on an OpenXML/Swagger schema, if there isn't already an SDK provided in your language of choice), or you use a type designed for holding more arbitrary data structures (e.g. a simple string->object Dictionary, or some sort of dedicated JsonObject or JsonArray type).
There are issues around JSON and static typing, e.g. should you treat JSON with extra fields as an error, date/time handling, how to represent missing values etc. (and whether missing is semantically different to null or even undefined), but I would hardly see it as "one of the most annoying things".
Edit: the other issue I forgot is JSON with field names that aren't valid identifiers, e.g. starting with numerals or containing spaces or periods etc. But again, not insurmountable. I'd actually be in favour of more restrictive variation of JSON that was better geared towards mapping onto static types in common languages.
Probably makes sense to make the "string" key "s" for minimal payload size impact, or this could also be done by converting standard JSON to this typed representation at runtime.
You should check what the external API returns anyway. If only because the format of any external API might break in subtler way than just returning a single Unicode snowman, and you'll want to ensure that all such failures are correctly detected as soon as practically feasible.
Likewise in Rust you can todo!() in incomplete functions. But it’s still much harder to run incomplete code than it is in javascript, because you still need to satisfy the type checker and borrow checker even for incomplete programs.
Per #1, why can't it be per module or per routine? Framework-oriented API's we often want "tighter" while domain-centric glue-code can be looser.
Per #2, why not a language that facilitates "lint" style inspectors to give a list of warnings, as in "do you really wanna do that?" This reduces the burden of the compiler. Meta-markers can switch certain warnings off so we don't clutter the results for intentional spot looseness.
I think that if you permit, but don't require, type information, then there is little practical difference between failing typechecking at compile time and merely issuing warnings.
They cite haskell which, if anything, has even stronger static typing and more powerful type inference (because the rust devs decided to favour explicit clarity and removed global type inference).
Rust’s type inference still requires you to manually specify the input and output types of every function. I think it’s clearly in the camp of “all types must be specified by the programmer / you get compiler errors”.
The GP asked what’s the point of languages like this. The two massive advantages in my mind are that compiled programs are usually correct, and compiled programs run really fast. I often have the experience in rust of writing hundreds of lines of new code, and my program works perfectly the first time I run it. That’s a delight.
The decision to require explicit global type annotation in Rust has nothing to do with "compiled programs are usually correct, and compiled programs run really fast", you can do that with global type inference.
It has to do with documentation, intent, and clarity. Also clarity of error messages, compilation errors with generic functions under GTI are as bad as C++ template errors.
> It has to do with documentation, intent, and clarity.
I wish it were common [0] for source code to show a combination of programmer-specified and compiler-deduced details.
Seems like we could get the best of both worlds.
[0] I know some IDEs show e.g. type information as mouse-over text. I'm talking about something that integrates more cleanly into the actual source-code text. So, e.g., it would show up when diff'ing the source code.
This is absolutely false. Compiled programs might be correct with respect to type safety, but that is very different from being correct. There are all kinds of ways that programs can fail besides type errors.
In fact, this widespread false belief is IMHO one of the strongest arguments against using enforced-compile-time-strong-typing because it lulls you into a false sense of security. I have a long list of war stories where this led to problems that ranged from annoying to very nearly catastrophic.
Of course there’s lots of ways a compiled program could still be incorrect. But in my experience (having spent years with both languages) the practical per-line error rate of rust code I write is many times lower than my per-line error rate in javascript. (And typescript is somewhere in the middle for me).
Yes, you still need tests. But testing is never perfect. Bugs still slip through. I like not needing to be as paranoid in rust as I am in other languages.
Agreed, and Julia is in an odd spot as well. The type inference combined with JIT and dispatch act make for strong dynamic optional typing vs rusts strong static inferred typing.
This post seems to conflate runtime metaprogramming (and maybe operator overloading and proxies too) with dynamic typing. You can have runtime metaprogramming, reflection, and reified types with static type systems.
This is one of the things C# does extremely well. Expressions are an under-rated (and sadly under-maintained, e.g. no tuple support) feature of C# and one of its big differentiators versus other languages for me. I use reflection heavily as well.
I feel like Common Lisp should be considered in this article. I think it can implement the specific points mentioned, and (quote x) introduces Gödelian difficulties for type systems that even the Haskells of the world don't handle well.
It's in some sense the "most dynamic" language, because you start one instance and repeatedly mutate it. And people mainly use introspection as the main tool for finding definitions/help/etc. instead of static documentation, so you don't have a half-hearted REPL.
I'm more of a static types person myself, but CL would be what I compare them to.
First off, interesting blog. I can appreciate the author thinks that dynamic types are cool, and he/she/they like being weird and contrarian. That's cool. Me too. That said, I'd like to point out that the article focuses on dynamic runtime-metaprogramming, but (largely) fails to address it's statically-typed cousin, compile-time metaprogramming. I'd like to broaden the conversation a bit.
I'll quote the author:
> a dynamically typed language is one where types are runtime values and manipulable like all other values. It’s a short hop from there to thinking of the whole runtime environment in the same way, where everything is a runtime construct.
If you have a decent compile-time metaprogramming system paired with a statically typed/compiled language, (almost) the same statement holds true. It's not as easy to, for example, instrument a production instance with brand new code at runtime, but the mental model while programming is very similar.
> The overall picture is of going one step further than homoiconicity: instead of code as data we have programs as data.
This is the heart of what metaprogramming is all about and, to my mind, really has nothing to do with static vs dynamic types.
The author finishes with 'crushing disappointment'. I somewhat share this sentiment, though I have great hope as well; there are some incredibly talented developers working on the compile-time version of these problems. Maybe one day we'll have good languages. I guess the only thing we can do is continue writing new ones until we do ;)
Came to say roughly the same thing. The post seems to conflate a number of things. Static/dynamic is about compile time (in dev environment) and run time in (production). Not being opposite doesn't mean that you need much of both depending on how expressive the compile time is. Types being runtime values can also work during the compile runtime as noted.
A very good example to look at is the `comptime` in Zig, it could be enlightening and the opposite of 'crushing disappointment'.
> I don’t know if any of these are impossible without runtime metaprogramming, but they all seem like they’d be more difficult.
Not really? I mean it is more difficult in that you have to specify all sort of things which are left unsaid in the article's snippet, but fundamentally you just need the ability to define or override the act of "calling" an object, aka
void operator () ()
or
impl FnOnce<...> for <...>
Something not all statically typed languages have, but not all dynamically typed languages either (doesn't exist in javascript for instance, you can staple shit on a function object or extend Function but AFAIK the language has no callability protocol).
> I can imagine all sorts of uses for this kind of trick. Here’s some I’d like to see:
Most (though not all) of these are already better and more generally solved via introspection tooling.
> I guess the closest to the hyperprogramming model is Pharo? The dynamic code inspector is a big deal, plus there’s this fun talk.
The author should take a look at Self. It has similarities with Smalltalk systems, but is an other window into an... interesting world.
In the end, static typing is just a means for the reasoning you do in your head about why your program is correct to be checkable by the compiler. Arguably, if you’re unable to explain that reasoning to a compiler-like checker, then you shouldn’t trust your reasoning. Of course, static type systems aren’t quite there yet in practice, but that’s the ideal. And runtime metaprogramming is no different. You have some reasoning in your head why your metaprogramming code is doing the correct thing, and it should be possible to have a type/theorem checker that validates your reasoning.
So static typing vs. dynamic typing isn’t about what you can or cannot do, it’s about having a mechanism to verify the soundness of your reasoning vs. having no such mechanism.
Unless you are using Haskell or Rust then static typing is a terrible way to check if your program is correct.
You need unit tests to confirm the correctness of your program. And unit tests check most of the types for free. So you don't need static typing after all.
The person you're replying to didn't mention this, but I find the main utility of static typing to be that all variables and functions either have explicit types or their types can be inferred by the IDE.
Great for readability when I'm wondering what type this parameter is supposed to be and what I can do with it.
You can obviously have documentation of types without static types, but static typing means you have ever-present compiler-verified documentation of types. Not always enough, but it's a lot more helpful than just having nothing.
Having exhaustive unit tests doesn't necessarily help with this.
AFAIK, there is always a separation between the program (represented by the code) and the process (the execution of the program by the machine). To “hyperprogram”, we would need to merge the two. I view the program as a space-based entity and the process as a time-based entity. How do we map time to space? Especially if it's a non-deterministic process?
Lisp macros allow us to specify multiple versions of code at compile time. And Lisp conditions allow us to redefine or discard runtime contexts. But this time-based manipulation is simpler than what you seemingly want. The easiest way I can think of is to offer an API to track the evolution of types, but I'm guessing the performance hit would be heavy.
Smalltalk traditionally breaks this source vs. executable state dichotomy by using a single memory image where everything is an editable object, including the classes that contain the code. For persistence, the image is simply stored and reloaded in its entirety.
I really, really don't mind if people have preference for static typing. I do too - not as strong as the 2022 zeitgeist to be sure but I still have it.
What I dislike is the inference that people who prefer dynamic typing are ignorant, that they just haven't discovered hindley-milner and need to be converted.
A lot of incredibly smart, talented programmers chose (and choose) to do work with dynamically typed languages. Don't assume it was because they knew less than you.
It's honestly the opposite for me. I need the mental crutches provided by strong static typing.
TL;DR:
I work with a lot of very smart, very talented software developers who thrive with Python's dynamic qualities. They have little problem reasoning about our software's runtime behavior.
Unfortunately, I'm a bit less intelligent than them. I have a much harder time understanding the code's design and behavior due to that dynamism. I would really benefit from the crutch of strong static typing, and the more obvious relationship between static source code and runtime behavior.
Same, but the best software engineers write code that's maintainable and understandable by anyone, including themselves on 5 years.
If you ever find yourself asking what's actually in this variable, and the code is too dynamic for the IDE to tell, it's going to be too hard for a lot of developers to tell, and maybe even the author themselves in a few years.
If the type hints are too complex and involve multi line annotations with 500 unioned types, then we should remember: the programmer does still have to remember what type that parameter can be, even if it's not annotated. Leaving out the annotation doesn't simplify the program, it just moves some information from from explicit to implicit.
Calling static typing a crutch is like calling a passenger airliner's takeoff checklist a crutch. I mean maybe it is from one perspective...
Adding optional type hints in Python is no problem and does not hurt performance at all. You could add them without changing anything else in your project, except imports for type names.
I find that in software with a strong static typing, I can generally understand each function or class on its own, without much consideration of the context(s) in which it's used.
If that same software were written in e.g. idiomatic Python without the use of typing hints, I often need to read a lot more comments, and look at the particular contexts in which that function or class is used, in order to understand its intended purpose and supported use-cases. Things like monkey-patching make it even harder for me.
So for someone accustomed to understanding a piece of software by reading the source code (i.e., human, static analysis), a big software system written in highly dynamic Python has a really long learning curve.
Even Java is pretty terse these days, but even if it wasn’t, you spend far more time maintaining and editing old code than you spend writing brand new code. All the stuff that looks like verbosity is information that helps you.
I’ve also seen monkey patching done in crazy ways even on small projects. Languages like Ruby pretty much encourage it.
I can’t imagine what it would look like to edit a Python or Ruby codebase that was a decade old and had 1000 people working on it. But I’ve done that in Java fairly easily because the types help. A little bit of “public static final” here and there is a small price to pay for more maintainable code in the long run.
I've written countless programs with both static typing and dynamic typing, side by side. The statically typed programs are three times as long.
It's not the type hints that are responsible. It's the supporting code for allowing the usage of static type checking. Abstract base classes, interface, generics, that stuff which just doesn't exist in the dynamically typed world.
As someone who does Perl, python, Java, C#, and various flavors of JavaScript on the regular - it’s different tools for different situations.
I’ve worked on million+ line Perl codebases, and even bigger FAANG codebases in each of those languages, and untyped large code bases turn into impenetrable balls of mud much quicker than typed ones.
And myself and others were doing runtime meta programming in Perl long before it was cool or anyone we knew was calling it that.
But never underestimate the ability for a software engineer to create code in any language that will perplex and mystify them a year later.
And I’ve not met a codebase in any language yet that couldn’t be turned into a ball of mud pretty quickly.
Becoming an impenetrable balls of mud also depends a bit on the language design. For example in Python one can implement how objects respond to operators (double underscore stuff), but that hides the characters of the object. In other languages one has separate functions for doing things with different types like string-length, vector-length, and so on. When you see that in the code, you immediately know the type of that argument of the function, because it is explicitly in the name of standard library functions. It is a language design choice.
Personally I do both. If I'm using a scripting language like Python I'm all for dynamic typing and if I'm using a programming language like Rust I'm all for static typing.
They are different development experiences, one isn't better than the other. If you want to ship something to market tomorrow use Python with dynamic typing. If you want something to run fast and be error free use Rust with static typing.
After ~30 years of professional software development in pretty much every major language created, I prefer dynamic languages to statically types ones for a whole host of reasons, none of which amount to me being a n00b or uneducated, which is frequently implied by the static types die-hard folks.
Not the OP, but most compilers for languages with static type systems are too slow for my taste. I have a workflow for writing lua programs in which I embed all of my tests in the same file that contains all of the runtime logic. Whenever that file changes, I have a monitor script that runs the program and validates my tests. This process typically happens in less than 20ms, so I've configured my editor to save my lua files whenever I type a single keystroke. It's like having a living REPL session that is persistently stored in the file.
When you develop in this style, you tend not to write type errors because you are constantly validating your program inputs and outputs, which the tight feedback loop encourages. Most statically typed languages have slow compilers. It is not uncommon for an incremental compile to take at least 1s, which is 50-100x slower than what I'm used to. Indeed, I find it distracting when compilation is that slow so with such a language I will still use a monitor script for iteration but I will manually save when I want validation. This means that I am not only getting slower feedback, I am getting less of it, which ultimately makes me less confident in my program. If I were making lots of type errors, maybe it would be worth it to have a type checker, but I'm generally not.
I think it's mostly about time to market and reduced total LOC.
I find that in strongly typed languages, frequently up to 30% or more of the total lines of code are dedicated to nothing but type satisfaction.
Sometimes this formalism is warranted, depending on the problem domain (banking, air craft systems, etc) but most of the time I find that the type systems are merely mental abstractions that are created to assist the developer in modeling the solution.
There's value in that.
As I've gained more experience, trivialities like the compiler catching junior programmer level silly type mistakes has become less valuable. Type systems don't address logic errors.
The flexibility of duck typing with loose contracts reduces total time to market for applications where that's a primary constraint.
This happens to be true in a vast class of problem domains, where having something quickly is more important than performance or formal correctness.
Since I tend to live in the startup space where TTM and MVPs are critical to business success, I'm inclined towards languages that support this.
I typically don't have to deal with large team coordination issues or extremely complex interdependence, and my systems are normally fairly basic - crud with business rules and a fancy UI.
In this case, things like type errors present themselves immediately in the UI and are easy to catch during development, reducing the value of compile time error detection.
Wait till this guy discovers Lisp, self-modifying code, or eval()! Code and data are exactly the same thing, just stuff in memory!
The problem is that infinite malleability is cool, but a lot of tasks do not benefit from it. Static typing, on the other hand, keeps you from making a lot of dumb mistakes. Static typing lets you spend time on your logic, whereas with dynamic typed languages like JS or Python, you spend time on logic AND on typos, misremembering function arguments and or positions, etc. The thing is, most routines can only really accept one or two types, and only very rarely does one want to treat data as code.
A word processor, or a sorting algorithm, or banking library have no need to modify their code; in fact, doing so is likely to break something. The sorting algorithm would benefit from polymorphism, but the others would not. You cannot render an image the same way as text. And the banking library had better be adding integers, not strings or even floats. However, all programs will benefit from the compiler saying "the function doesn't accept this type" or "this variable doesn't actually exist".
This guy should find a job maintaining a complex Python server, he'll learn why people like static types. Nothing like digging through a bunch of code to find, "ah this looks like the function I need, what does it take? Oh 'connection'. What's that?" Static typing at least forces a minimum of documentation. Or you make a change, server runs but still not fixed. Then you notice it's not even doing what it used to do, but no errors or anything. After tearing your hair out, you discover you swapped two letters in a variable, which caused a syntax error exception loading the module, which was silently discarded by the dynamic loading code.
> “This guy should find a job maintaining a complex Python server, he'll learn why people like static types.”
This guy wrote several books on formal methods. Not only does he know why people like static types, he works to encourage people to adopt more advanced forms of static analysis.
> I encounter a lot more smug static weenies than smug dynamic weenies, so I defend dynamic typing out of spite. There have been a few cases where I was surrounded by SDWs and I immediately flipped around to being pro-static.
I find such statements incredibly daunting. Like the person is acting like a child for no other reason then to be contrarian. I'd hate to work with such a person.
The persona someone adopts for a blog where the goal is to spark discussion and learn is probably different from the persona they adopt at work where the goal is to work with others and get things done.
My suspicion is that what makes this hard is using higher-order dynamic programming facilities. If you just stick to first-order functions with relatively simple data structures and primitive types then there's a lot of cool, interesting runtime gadgets you can put in.
But the moment you try to use the full arsenal of dynamic reflection, metaprogramming, etc. then the runtime checks and gadgetry needed to analyze all of that explodes in complexity and even if it's possible to make an analysis framework capable of analyzing it, the results might be very difficult to interpret.
So as long as you try to stick to first-order constructs as much as possible, you can get quite far.
But, conversely, the simple cases that you describe can be handled quite nicely without losing static typing. You just need union types, and some simple type inference. So then why bother with dynamic at all?
This doesn't seem specific or particularly enabled by dynamic types?
I don't really understand it yet and have barely used it but this seems like it's supported by expression trees / Roslyn / code gen stuff in .NET/C#?
Is there a difference I'm missing here, e.g. writing some lambda and having the code generate code to run it against NoSQL or SQL or some other storage at runtime which is what I think things like LINQ2SQL do under the hood is basically equivalent right?
This type of programming seems like it can be supported by both types of types?
Fragility of the more clever dynamic typing stuff like the "Replacer" is a big problem. An even bigger problem is the increasing difficulty of composing those things together when you want more than one at a time.
And that difficulty scales up fast. Consider the Python function decorator. It was quickly discovered that the decorators need to preserve the metadata for the function's name, etc., or the decorated functions broke in places that looked at that metadata. But if you have a "function name" parameter that the decorators can modify, now there's a mismatch between the fact that the function claims to be "the original GetUser function" but it's actually the decorated function. So now you ought to have "the visible function name" and "the real function name", so for instance, the former works with metaprogramming correctly but the latter allows for correct debugging output on crashes.
But what if you decorate a decorator and then want to do metaprogramming on it? Now you're got the apparent function name, the real name of the function beneath it, and the real name of the function beneath that.
And this is just one dimension of problem. There are many others when you get to metaprogramming like this.
The core problem is really this style of metaprogramming, in my opinion. It almost inevitably slips into becoming Deception-Based Programming, in which increasing swathes of the program are trying to figure out what lies to tell to other chunks of the program to get the desired effect. Dynamic typing makes this harder because it makes it very difficult to get a manifest that lists all the behaviors this particular chunk of code or code object has, but even if you were doing this in a static language that didn't make that hard, a language would still have a problem exposing all the correct properties and assisting programmers in getting them completely correct.
It's fun at first, and very powerful at first. But deception-based programming is one of the quintessential ways a code base fails to scale and becomes an incomprehensible tangled mass, code bases that use heuristics to figure out what lies to tell to other bits of the programming to cover up the effects of the lies the other bits of the code are telling. This leads to your programming becoming an embodiment of the phrase "What a tangled web we weave, when first we practice to deceive".
That said, before someone pops up with "but we could do that without deception"... I agree! Or at least, I agree that it should be explored better. I would be interested in a language that somehow enables such metaprogramming, but with a philosophy that says that it will stay "honest" throughout, and basically deliberately eschew and exclude this sort of "transparency". I don't think it's necessarily intrinsic to the style. I just think that it's sooooo tempting that if you don't build a language that excludes it from the get go that the temptation to just shim this little behavior in just here is too tempting, plus the languages tend to end up requiring it, be it accidentally or deliberately, because they afford that behavior and the language ends up being built around how it is at least supposed to be possible. I don't know entirely what this would look like.
Edit: I say I don't know what this would look like, but I have had a couple of ideas. One I've talked about before is to have a clean separation between an "initialization" phase in which metaprogramming is legal, and a "sealing" phase in which it is all done and the metaprogramming becomes invalid/illegal. I wrote this for the runtime not to have to be super flexible all the time, but it works for programmers using the system to know that things won't change out from under them.
The other is some convenient and powerful way to dump things out in their final state post-manipulation. I've gotten some good mileage out of this in my own code that is using a lot of composition. It is very helpful to be able to take the top level of the composed object and say "Tell me what exactly you are made out of". Even if I'm not doing metaprogramming based on the response, it's a huge debugging help. I see traces and fragments of this idea here and there, but no system that has it coherently integrated in a way I'd like to see.
It seems like to make this work, we need clarity around what's the public interface and what's the implementation.
Some implementation details need to be visible to debuggers and for analysis, but hidden in "regular" code so that you can substitute a different implementation without invalidating constraints.
For example, functions normally have names for debugging purposes, but the names can't be relied on in "regular" code or you wouldn't be able to rename them without breaking things. It may be better if the names aren't visible at runtime at all.
The reason Python function decorators have these issues is due to a lack of structure; a lot of metadata is just there for anybody to use, with no constraints.
It's also possible to do this by convention - for example, many things in Go's reflection package will break abstraction boundaries if you use them the wrong way. It's less of an issue if you have strong language conventions against doing that. In many scripting languages, the conventions for when you can use metadata are weaker.
The "Mirrors" system by Bracha and Ungar [0] is intended to address this IIUC. It's obviously a big, very messy problem, but without a good meta system, languages are forced to grow large and messy on their own in order to be appropriate for a wide variety of programming tasks, or to overuse compile-time macros/templating to similarly hairy effect.
It's the programming version of being penny wise and pound foolish. This style of metaprogramming reduces a few lines of boilerplate code at the expensive of making a system as a whole extremely difficult to understand or maintain.
Yes. I suspect this taming of complexity and severe uniqueness of each codebase with it’s clever “patches” applied through meta programming is why we have the meme of the lone Lisp programmer writing their dependencies from scratch.
Having restrictions like types and no meta
programming is less “fun” but the new person on the team will get up to speed more quickly.
The one job where in C# actually they used a tonne of meta programming I could barely get anything done. Because of course
the documentation is non existent and the codebase so big would take you years to comprehend, or trying to get some help from never-free team members.
Been thinking about this as well. Working on language native libs to react to eBPF telemetry and scale logic forks appropriately to avoid exhausting the host, motivated by disdain for Docker/k8s. I’d rather just import a dep and develop the behavior.
But I don’t have to time to write all that. So I have actually been writing helper scripts to generate object code and function signatures via ast libs.
This is how I originally “learned programming” in the 90s. The industry zeitgeist shifted to “churn out code” and all that wizardry was lost. I have no idea how to prove it but I sometimes think it was intentional to capture worker agency.
It’s been fun reconnecting to it but the ecosystem of helper tooling … well there is none. Tons of blogs with basics about for loops though.
For example, there is an SQL provider that gives you generated types and auto-complete but it does this without running a source generator that emits files. It just happens inside the compiler.
A big reason why this kind of stuff doesn't quickly become unreadable in C# is that the output of a source generator must be a new file. So the ability to extend existing code is limited to the facilities that the language specifically provides for this purpose (partial classes & members, extension methods etc). It also means that tooling can treat all such generated code as virtual files, allowing them to be inspected, debugged etc.
I recommend learning static typed languages first, maybe even working in a large code base. Then you will truly appreciate the ambiguity of dynamic languages and look at them less like “baby languages”.
In app code bases, there are always multiple duplicates of the same type, no one truly respects reusability partially because it’s not always practical.
With dynamic typing, you can be completely transparent and have multiple “Car” classes. It doesn’t matter because types don’t matter. To us they both represent a car, no need for converters, helpers etc. Car1 definition has some method you need for Car2? No problem, just tack it on in that scope! It’s not polluting the definition of Car2, it's not going anywhere.
What does this have to do with dynamic typing specifically? A language "where everything is a runtime construct" can only be a language where there's no phase separation between compile time and run time. So it makes just as much sense to say that everything is a compile time construct.
Not all programs have a compile time (and for those that do, there's a one-to-many relationship between it and runtimes), but there's always a distinction between runtime and write-time. I do not see how one could write a program without having in mind specific semantics for each identifier you introduce, and if they have specific semantics, are they not at least implicitly typed, whether or not they are statically typed?
The author seems to want to use dynamic typing in a restricted manner, to help in testing and debugging and understanding a program, particularly within a REPL. Further down, he seems to be saying that the lack of empirical evidence on how this works in practice (on account of it rarely, if ever, being used in available tooling) seems to suggest it is difficult.
Why do you assume that one can't assign a semantics to code that happens to be included within the program as a runtime construct? Such semantics might even be "compile-time" checked in a conventional way, if one can come up with a machine-checked proof that the runtime treatment of such "constructs" does preserve whatever semantics one cares about. Of course this runs into concerns with Turing-completeness in the fully general case, but for many practical scenarios such a check might well be feasible.
I'm not saying you cannot do that (and, specifically, I am not saying you cannot do the sort of things the author seems to want to do.) I am saying that I don't see how you can write the program in the first place, unless you have some semantics in mind for every identifier you introduce. To add to that, I doubt to can perform a runtime substitution unless the substitution conforms to the semantics of the original design. This seems to be the case with the author's examples, which extend the original types with a sidechannel for getting information about the execution of the program.
Shame that "dynamic types" is often conflated with just non-static.
My favorite "dynamic" type that is stupid useful has to be numbers in Common Lisp. (I think most all lisps, actually?) Having it widen to be as big of a number as you need it to be, while you are using it, is stupid useful.
Sorta. It is dynamic in that the "type" of the data can literally change as you use it. I don't have to say that an input is rational, and it may only become one after I use it in a way that requires that.
Sorta. Inference can't tell how big of a numeric type I have to support based on just the static typing.
You probably could just call the data statically typed, but with the idea that the concrete type that you are actually dealing with could be any of a few types. Think a function in Java that takes in a Number, but obviously has to have code to work with any actual numeric types. Even more fun, the code could do basic bounds checking to make sure that the result you are working with fits, such that the code starts with an Integer it is working with, but auto moves to Long, etc.
And that is where I wish dynamic typing focused more. It isn't that the type of things is not knowable. Clearly they can be known. And it is nice to have parameters constrained to be within a range of expected types. The actual type, though, is dependent on runtime data.
Does CL have something akin to schemes "numeric tower"? number -> complex -> real -> rational -> integer.
Always idly thought that could work in a statically typed lang and was a feature well worth copying (how many languages REPLs are useless as calculators due to floating point errors!)
The general idea should be fine. The problem gets in that I have to make a choice up front in many static languages. Something I don't have to do if I give up what exact type of number I am using at the moment.
You want something called higher-order unification. For it to work you need totality checking. What you want is dependent types. Dependent types are sensible, navigable, comprehensible, ah-what-I-wrote-last-week-is-understandable metaprogramming.
I'm not going to pick on anyone in particular but I came to the comments with a sense of forboding that people saw the words "static" and "dynamic" and wrote stuff unrelated to TFA.
I was not disappointed. This article is complex and interesting and deserves better.
Yeah, homoiciniciry is one of the "facilities" I mentioned. Along with stuff like pattern matching over types. But yeah, macros are really one of rhe big ones.
My personal opinion has become, do I want to write leaky tests to validate typing (in addition to functional tests) or do I want to let my compiler do my type validation for me in exchange for the boiler plate on the front end with my IDE providing auto complete along the way?
As a Java programmer, you can guess where I lean. Now excuse me while this autowired annotation Dependency Injection causes a new self inflicted level of hell.
I don't know if I can make the claim that if a program behaves to spec, the types must be right (that seems like an academic statement out of my own wheelhouse). But I've always scratched my head at the canard of "static types saves time from obviating the need to write a whole class of type validation tests", that just doesn't map to my own experience.
I challenged my intuitions on this recently and checked out some of my existing unit tests suites to see what my type error coverage looked like. It was exceedingly rare that I was able to fine a behavior assertion which after introducing an intentional change to cause a type error, didn't blow up the test.
The cases where I did find type errors that snuck through with false negatives, the test itself was actually written poorly and the same behavior assertion would fail in other ways unrelated to types.
I'm sure there are situations (in my own stack and in other folks') where my above findings are contradicted but, given that static typing for something like JS is not in any way a zero-cost abstraction, I'm not sure it's really a net win.
I may be misunderstanding what you write, but I've had many (many, many) programs that worked for the wrong reasons, which led them to break in very (very, very) ugly ways due to unrelated changes.
The only ways I know of to get rid of (some of) these bugs are to:
- use as much static typing as possible;
- wherever static typing is not sufficient, sprinkle this with a paranoid dose of assertions.
If I read what you write correctly, that's way beyond "testing for the correct behavior", no?
> which led them to break in very (very, very) ugly ways due to unrelated changes.
Sounds more like a problem with tight coupling than a problem with types per se. A function should not change behavior due to an "unrelated change", regardless of static or dynamic typing.
Let me give you a trivial example (with TypeScript-style syntax, but assuming no TypeScript-style type-checking):
```
function foo(config: {hasYakShaver: boolean}) {
if (config.hasYakShaver) {
// ...
} else {
// ...
}
}
foo({hasChuckYeager: false});
```
This will accidentally do what I want, but for bad reasons. And one day, quite possibly, a change that appears unrelated will break this.
And that's me being nice. If I start using reflection to do complicated stuff, or if I'm using C++ and the reason for which my code works is memory corruption... well, when I need to figure out what suddenly broke the code, I'm going to be up for serious a moment of loneliness.
Sure, static typing will catch such issues. But there is still no guarantee that the foo function (or the function calling foo) works correctly just because it passes the type checker.
If you have unit tests (or other types of automated testing) you have a much bigger chance of catching logical errors and it will also catch trivial error like misspelling an identifier.
> Testing for the correct behavior should be enough.
Also, in my experience, automated tests tend to catch many bugs, static typing also catches many bugs and defensive programming catches many bugs, too. There is an intersection between all three, but it's by no mean 100%. So I use all three of these.
And of course, let's not discount monitoring, fuzzing, etc.
You're not going to cover all of the correct behavior in any nontrivial software with unit tests unless you plan to spend 99% of your time writing unit tests. Static typing gives a good baseline of correctness.
> Static typing gives a good baseline of correctness.
Not really. Lets say you have an `add(a, b)` function. Static typing can guarantee that the function returns a number, but not that it returns the correct number. So you need unit tests anyway.
A unit test `assert_equal(4, add(2, 2))` actually tells you something about the correctness about the function, and it implicitly also verifies that it returns a number. So static typing does not save you anything.
Static typing does have advantages, for example for documentation and for automated refactoring. But it doesn't replace any unit tests.
Sure, in this trivial example there may not be clear benefit, but with more complicated code, operations on complicated structures, with potential state changes, etc. there are many benefits. Even in your trivial example you don't cover the correctness or correct usage of your function - since it's dynamically typed it could be called with non-integer arguments - what's the expected behaviour in those cases?
Static typing certainly have many benefits, just as it has a number of drawbacks. But I'm not making a general argument about static versus dynamic typing, I'm just rejecting the specific argument that static typing saves you from writing any unit tests.
Nobody made the argument that static typing removes the need for unit tests. It does check for many conditions that would have to be unit tested otherwise, such as calling functions with the wrong types.
I'm saying static type checking does not replace any unit tests, because you wouldn't write tests which only checks for "the wrong types" in the first place. You should check for valid behavior - i.e. is the output correct, not just of the expected type. Types are just an implementation detail. But if the tests verify that the output is correct, then it implicitly follows that the types are correct, so you get type checking for free.
No, as I've pointed out - even in your trivial example you're not testing for all correct behaviour. What's the correct behaviour when the function is called with invalid argument types?
> But if the tests verify that the output is correct, then it implicitly follows that the types are correct, so you get type checking for free.
Your unit test checks the correct behaviour for one out of infinite possible inputs. You don't 'get type checking for free' , what you get is no type checking at all.
This is true - unit tests generally does not test all possible inputs. They test some representative examples and edge cases. In most cases there are infinitely many possible inputs, and you get diminishing returns the more cases you test.
If I understand you correctly, you a suggesting unit tests in a dynamic language should test all possible invalid inputs. Since statically typed languages eliminate a subset of invalid inputs (because they won't compile), statically typed languages saves you from writing some of these tests.
My argument is that you wouldn't write such tests in the first place since they provide little to no value.
If function `foo()` calls function `bar()` you want to ensure that foo passes the correct arguments. You don't do that by checking that bar() rejects all possible invalid types. You do it by checking that foo() actually works and provide the correct result. The types involved are an implementation detail.
> You do it by checking that foo() actually works and provide the correct result.
But you're testing a tiny subset of possible scenarios of behaviour of the function. If you can anticipate all possible input types and value ranges, maybe unit tests are enough, but that's not realistic in a dynamic language/program where complex values are non-deterministically generated at runtime - based on user input, db, file, etc.
In any statically typed language I know, it is possible to create invalid values which looks correct to the type checker. E.g. if a function takes an interface as parameter type, you can create a dummy implementation which throw exceptions for any operation. So basically you have the same problem as you describe for dynamic languages.
If you are talking about untrusted external input then yes, you need to carefully validate and reject invalid input. If you receive JSON or a CSV file or whatever, there are an infinite number of valid inputs and an infinite number of invalid inputs. But this is the same for statically typed languages, and the type-checker will not help you, since a valid JSON string and an invalid JSON string look exactly same to the type checker.
> E.g. if a function takes an interface as parameter type, you can create a dummy implementation which throw exceptions for any operation. So basically you have the same problem as you describe for dynamic languages
That's not at all the same problem, because here you're intentionally writing code that throws exceptions for some reason? Also, many languages, such as Java will not allow you to throw a checked exception that's not declared on the interface method - such issues will be statically checked and caught - thanks for proving my point ;)
> of valid inputs and an infinite number of invalid inputs. But this is the same for statically typed languages, and the type-checker will not help you, since a valid JSON string and an invalid JSON string look exactly same to the type checker.
Not at all, in a statically typed language the JSON would typically be deserialized into a structured type with all the benefits of type checking, validation and usage of the resulting values. Of course it's possible to just use a JSON string directly, but that's not idiomatic and generally not the way it's done in quality codebases.
This kind of thing is really what contract-based programming is meant for; it shouldn't properly be a test, except that we have to resort to that for the lack of language support for DbC.
I really disagree with this. I use static types as a quick correctness check.
I've added types to large python projects to reduce the complexity of reasoning about the code base, and the typing does not make it run faster (actually, the opposite).
I've got lots of experience in developing with both static and dynamically typed languages, I know the differences and it has very little to do with anything you are talking about.
Dynamically typed languages tend to be simpler and easier to use. This means on the whole they have less bugs than their static typed counter parts.
The real issue with dynamically typed languages is that their performance sucks.
(That and according to this thread people not having a clue on how they are properly used. You can't just use the same development techniques as you use in a statically typed language. It's different.)
> I've got lots of experience in developing with both static and dynamically typed languages
And yet your comments demonstrate the opposite.
> Dynamically typed languages tend to be simpler and easier to use.
Very debatable.
> This means on the whole they have less bugs than their static typed counter parts.
Not at all.
> The real issue with dynamically typed languages is that their performance sucks
Every time you say something like this it just makes it obvious you have no idea what you're talking about. You can write almost completely typeless, highly performant, c and assembly code, while high level languages with advanced type systems are generally not the most performant.
You seen to be confused about interpreted/jit/compiled languages and static/dynamic typing - which are not the same thing.
> The article covers a study of the same name. In it, researchers looked at 400 fixed bugs in JavaScript projects hosted on GitHub. For each bug, the researchers tried to see if adding type annotations (using TypeScript and Flow) would detect the bug. The results? A substantial 15% of bugs could be detected using type annotations. With this reduction in bugs, it's hard to deny the value of static typing.
> Yes, I'm sure the LOAD instructions take pictures of cats as operands via their typeless instruction sets... What in earth are you on??
Have you written any assembly? Assembly generally has no or very rudimentary type checking, you're generally dealing with words/bytes and addresses, you can arithmetically add parts of strings, divide pointers, etc. Errors due to these operation will surface at runtime, not be typechecked.
> well once someone does that let me know.
You can use void pointers as return types and arguments for all functions in c code. The effect is significantly less type checking while having equivalent performance.
"A substantial 15% of bugs could be detected using type annotations. With this reduction in bugs, it's hard to deny the value of static typing."
Actually, that makes it really easy to deny the value of static typing. If the total number of bugs in dynamic and static code is the same. But some bugs in dynamic code would be caught by static typing checking.
We most conclude that adding static typing results in a large number of non-typing related bugs being added to the code base. It's simple maths.
"Have you written any assembly?" Yes, I'm an emulator author, thank you very much.
The registers and opcodes are typed with things such as u8, u16, u32, u64, i32, i64 and only work with data of the right type.
"arithmetically add parts of strings, divide pointers" You mean standard C stuff, you know the statically typed language.
"You can use void pointers as return types and arguments for all functions in c code"
Dynamic types is not the same thing as type eraser. That void pointer doesn't carry the information that it points to a picture of a cat for example.
The fact you don't understand the difference between a void pointer and dynamic typing doesn't exactly surprize me. It's more like a giant vtable.
> If the total number of bugs in dynamic and static code is the same.
Completely baseless assumption.
> Yes, I'm an emulator author, thank you very much.
Ahahaha let's have a link then.
> The registers and opcodes are typed with things such as u8, u16, u32, u64, i32, i64 and only work with data of the right type.
Those are sizes, not types, and the same opcode generally applies to signed and unsigned integers. You consider that to be a static type system and you think the purpose is performance and not correctness? Lol
> You mean standard C stuff, you know the statically typed language.
You're trying to debate against the need for static typing by pointing out unsafe parts of c? Ahaha
> That void pointer doesn't carry the information that it points to a picture of a cat for example.
First of all you can totally carry around runtime type info and value with a single void pointer. Not to mention many dynamically typed languages have type erasure and many statically typed languages have runtime type info. Also, you've claimed multiple times that there's no need for type checking whatsoever. You need runtime type information at runtime now?
Not OP, but as I understand it, it is about adding tests to check what the behavior will be if the input parameters have values outside of the expected type. E.g. what will be the behavior of "add(a, b)" if a or b are a string, null, array, object, undefined, etc. instead of numbers.
But why would you do that? Sounds like a waste of time.
If some other function uses `add()` but pass an invalid argument, then this bug would be discovered by testing the function which pass the invalid argument. Presumably that function would work incorrectly.
Say you have a python function that expects a dictionary, but instead a string gets passed in. At some point in the function the python function may or may not explode at runtime depending on how it's written. If your functional tests doesn't cover this scenario and you haven't guarded for invalid data types in your python code, you may never see this issue until it hits production or a refactoring of the code base is done.
Ok, now that entire scenario goes away with a language that has a compilation phase and has type enforcement. It's boiler plate inside the function for type validation your don't have to do inside the python function (or blame the caller) and a testing scenario(s) you don't have to write. That's the work I'm talking about.
The bug is in the function which passes a string instead of a dictionary. Presumably this function will work incorrectly, right? So the unit tests of that function will find the error.
Unit tests which verifies a function has the correct behavior gives you "type checking" for free.
You just have to shift the mindset to testing behavior instead of testing types.
The problem is that the function may work incorrectly by e.g. quietly causing an invalid state change. For example, that string that was passed instead of an integer gets stored somewhere. Nothing visibly breaks; it's just that later - possibly much later, and in code much removed from where the bug actually is - you reach for what you expect to be an int, but get a string instead. Good luck debugging where it went wrong.
You can also have bugs in statically typed languages. Perhaps not the trivial bug of passing a string instead of a number, but you still can have the (more likely) issue of passing the wrong number, i.e. a logical error.
Lets say `add(a, b)` contains a bug. Perhaps it doesn't handle negative values or overflow correctly or something else. But it still returns a number, just the wrong number. This kind of bug is much more likely. If this wrong number is stored somewhere you also might get obscure errors down the line and a static type system will not discover it. And this bug will be much more insidious and harder to track down.
So you need unit tests anyway to protect against logical errors. If a unit tests verifies the behavior of a function it will also implicitly verify the types without any extra work.
The case you describe is handled by some form of checked types. In Ada, I'd just declare the range and let the compiler handle it. In a more conventional OO language, I can have a wrapper type for that number which enforces all constraints in the constructor. Either way, the point is that things blow up as soon as possible.
Broadly speaking, this is the contract-based approach: at every data flow boundary, explicitly describe your contract, so that any violation can be detected immediately instead of propagating and poisoning derived state. Eager type checking is just a subset of that, true. That means that static typing is not enough, not that it's unnecessary.
OTOH unit tests cannot fully express contracts, because even with parametrization
you can only test so many of the possible inputs.
How do you define a contract which guarantee that the result of `add(a, b)` is correct? Not just that the result is a number, but that it is the correct number?
Of course `add()`is a silly example since this is already a built-in in any language, but consider a function `calculateSalesTax(product)` - how do you define the contract which guarantees the result is the correct number?
There is no way, in any language with any type system or any amount of tests (except maybe in a pure language with tests that cover the entire domain of the function, in which case you're just reproducing the entire function in the tests), to guarantee that calculateSalesTax produces the correct number in all cases. So the problem you describe exists equally for both dynamic and static type systems with tests. Dynamic type systems just rule out fewer possible errors (and fewer possible correct programs as well, to be fair) before the program runs.
Think of static typing as an autogenerated set of unit tests that ensure that for any given function, all arguments of the wrong type will be rejected.
For example, if a function accepts a string as an input, test it by passing in all other possible primitives and classes and ensure that the behavior is strongly defined and results in the expected exception class.
Then realize that if you haven't written all of those tests by hand in a dynamic codebase, you don't actually have code coverage. Anyone who works on your codebase could pass you an argument of the wrong type, and it might be in a code path that is rarely invoked and you will find out it's broken in prod.
> Think of static typing as an autogenerated set of unit tests that ensure that for any given function, all arguments of the wrong type will be rejected.
My point is that you wouldn't write such unit tests in the first place. Unit tests should test behavior, not types.
No, it's actually different, because in a statically typed language it would not even be possible to write code that would pass the wrong types of inputs, because it would not compile!
Also, if there are input values of the right type that cannot be handled properly, that should also be tested! That is the kind of thing tests are for!
Example: you write a function that divides two numbers. It's not possible to divide by zero. You should absolutely have a test that passes in a divisor of zero and asserts that some kind of custom DivideByZeroException is thrown. If you know that certain inputs are out of range, you should test them explicitly -- otherwise you're being sloppy. If there is a large number of inputs out of range, you should test all of them, and you can use a `for` loop if you have to.
The difference is, if you have a valid input out of range, that is all you need to test -- the language itself takes care of all the "wrong type" tests for you. Otherwise you have to write them yourselves, and if you don't, you don't actually have good coverage. I understand that it is customary in dynamic languages to not write those tests, but that is merely another argument for the superiority of static typing.
What you are saying all breaks down with larger codebases.
A codebase with 1000 people working on it benefits dramatically from static typing. Everyone who has tried to do the same thing with dynamic typing has learned that. And quite a few companies that started off with Python had to rewrite everything in Java.
If you honestly think that nobody ever passes the wrong type, you've never worked on a complex dynamic codebase with a lot of people. It happens all the time.
The more modern solution is to break the large codebases into self contained microservices. In which case dynamic typing scales indefinitely.
I've worked on dynamically typed codebases for a long time. Passing the wrong type is very rare. I mean how would the code pass the unit tests if it was passing the wrong type?
> Passing the wrong type is considerably rarer than the wrong value so need not be considered.
Passing the wrong value doesn't just happen randomly. It happens because of errors in logic many of which can be caught with a strong static type system.
> You shouldn't write test to validate types in the fist place. Testing for the correct behavior should be enough.
Yeah but with dynamic typing there's a whole bunch of behaviors you need to think about. You pass an int into a method that expects a string, what's the expected behavior? Or maybe something more nefarious, like a method that expects a Python dictionary but is passed in a pandas Dataframe, which has similar syntax with totally different meaning; what's the expected behavior?
Much easier to just specify what types you're expecting to work with and let a compiler or type checker deal with it.
> You pass an int into a method that expects a string, what's the expected behavior?
Probably an exception, depending on the language. E.g. if the method expects the argument to support the method "substring()" it would be expected to throw an exception if the passed object does not have such a method.
If you pass an object which support the expected interface but with a completely different semantics, then you probably get a weird unexpected behavior. But that would also be the case in a statically typed language.
The flip side of all that is that a lot of times those other data types that have the same interface will just work without needing to go through generic hoops. Of course, if your software is the kind that cannot tolerate errors, that's not good enough. Most software isn't like that, and the flexibility is often more useful than extreme safety.
The two examples are human errors easily avoidable by reading the documentation. And some mechanism that uses types like method overload should be transparent from a behavior perspective.
> The two examples are human errors easily avoidable by reading the documentation.
Okay, but that doesn't answer the question. We're in dynamic typing world; my method accepts any data type by design. What's the expected behavior when it doesn't receive the expected type? Do I add assertions and fail with an assertion error? Let the runtime fail for me? Silently convert the type? In any case, I need to add a test for that.
If your answer is "humans shouldn't make a mistake when calling the method," that's arguing for static typing, not against, because that's exactly what static typing prevents: a human making a mistake. Turns out static typing makes for a really good baseline level of documentation, as well.
> And some mechanism that uses types like method overload should be transparent from a behavior perspective.
Your method might work today for both a dict and a dataframe because it's only using simple accessors. But what if someone wants to go back and change it and they use something that's only on dictionaries, like the * operator? Your code would fine, your unit test cases probably all use dictionaries so they're fine, and it's only when you deploy to production and find out that someone's code elsewhere was incorrectly passing in a Dataframe that just happened to work, no longer does.
Static typing has a solution for behavior-based types too, they're called interfaces. Even better in languages like Go and Python (with Protocols) that use structural type checking for interfaces, rather than explicit interface implementations.
> In any case, I need to add a test for that.
You don’t test types. If you have a ‘trim’ method that accepts a string, you don’t unit test it with an array, unless you plan to add a test for every possible types. Static typing can help prevent a human calling it with the wrong type, but won’t prevent calling it with the wrong runtime values, like after casting an Any to string. Static typing do syntax check, not runtime check.
When I’m talking about testing behavior, it’s about testing that the code does what it’s supposed to. Not what the arguments is. So you test that ‘trim’ works on all strings, including empty. What static typing helps in checking assumptions when programming, but every bet is off when executing. Especially when interacting with the outside world.
If someone changes the code, it’s on them to notify everyone about breaking changes. If the documentation says that the method accept dict, and you call it with dataframes, it’s on you to check that it will continue to work with dataframes, not on me to ensure that someone is calling it with dataframes if I use specific code related to dict.
> If someone changes the code, it’s on them to notify everyone about breaking changes. If the documentation says that the method accept dict, and you call it with dataframes, it’s on you to check that it will continue to work with dataframes, not on me to ensure that someone is calling it with dataframes if I use specific code related to dict.
This is equivalent to re-implementing static types and a compiler with human processes. This isn't scalable.
Maybe, but using static types is not a panacea either. It only make it easier to assume that someone have correctly constructed the value and that all manipulation have been correct so far. The type checker helps by checking that if the assumption is true, everything down the line is also true. But only in the context of the code, not in the context of the process itself, where the assumption may be incorrect, or the chain is broken somewhere due to bad logic (that’s when off-by-one errors arise). Nothing is a silver bullet. Static types can make life easier by preventing some meaningless code, but they don’t ensure correct programs.
Program behaving to the spec is something you'd write a functional test for; but OP is most likely talking about unit tests. If you're testing e.g. individual functions in isolation, you don't know how they're actually used on the outside, so you have to test for invalid inputs that the language allows - just like in a functional test, you have to test for invalid inputs coming from the environment.
(There is a separate argument over what proportion of functional vs unit testing is optimal.)
I find static types invaluable as a machine-verifiable (to at least some degree) communication tool. It's mostly stuff you ought to be documenting anyway, but in a format that's way more useful than some notes in a readme, or a comment, or whatever. I find it alarming that folks claim to save time by avoiding them.
Interfaces and helper types and abstract interfaces and that sort of thing don't actually run, though... it'd be a neat trick to get anything resembling the bug rate of code-proper out of assistive structures like that. Besides, not all languages feature that so heavily in idiomatic code—not everything's Java.
Anyway, that "bloat" is documentation your computer can understand so it can tell you when it's wrong. The alternative is documentation your computer can't check. Or just skipping that and letting the next person eat the higher onboarding cost and defect rate, which I suspect is more often the case.
[EDIT] And it's not unit testing instead. It's in addition to. One's not a replacement for the other.
I like Typescript, it makes Javascript enjoyable to me. I use PEP 484 heavily in Python which help as well. I have no issues with Kotlin on the typing side of the house, val/var works well to infer types. I have issues with Kotlin in other areas (Jetbrains only ecosystem, threading (comes back to JS), compilation speed, personal stubbornness). I don't have any experience with Swift.
Edit: A note on val/var w/ Kotlin, Intellij/Android Studio makes it easier to determine what the type of the variable is without having to dig for anything, looking upstream.
I think we've largely settled on "doing clever things is bad, actually." At least, I have.
Your code should be obvious and self-documenting. Relying on language features to do a bunch of stuff behind the scenes more often leads to programs that are hard to debug and perform badly. How many times have you had to dig into C++ operator overloading compiler errors or someone being too clever with C macros (wait, that macro can return?!?) or someone overloading a Python built-in function in surprising ways. Coming across the short double example from the article in production code would make me groan. The "all sorts of uses for this kind of trick" the author lists sound like a nightmare for some poor employee to have to reverse-engineer five years from now.
I feel modern languages (Rust, Go, Swift) have moved away from clever, implicit behavior in favor of more considered and explicit code-efficiency mechanisms. For example making error handling (? error operator in Swift & Rust) an explicit first-class citizen, rather than some clever macro hacking; or the trend away from operator overloading and complicated class structures and towards explicitly declared interfaces; or the many restrictions on Rust macros[1] to help ensure they're maintainable.
I wouldn't conflate Rust and Go here. Rust does this right - it doesn't try to remove the "clever" features altogether but streamlines ergonomics such that they're rather painful to use in "bad" ways. On the other hand, Go is an antipattern of PL design - it avoids features to such an extent that you end up writing less readable / maintainable code because of it (just witness all the codegen before generics!).
In what way is '?' not first-class error handling just because it does not exist in the context of a generalized effects framework? I don't see at all why handling other effects is a requirement for error-handling to have good ergonomics in the language.
Honestly, I always felt a bit lost in the whole “static vs dynamic types” debate because I dislike how it’s framed. So “a language is statically typed if you know its type at compile-time”. Ok, so you have ParentClass and ChildClassA and ChildClassB that inherit from it. And you create a variable that holds objects of the type ParentClass. But do you know at compile-time whether it is ChildClassA or ChildClassB? Only if your type inference system is strong enough and your code is structured appropriately for such possibility to even exist. And do you even need to know it? Most likely not unless that one time when you do in which case you use some ugly reflection tricks and whatnot. And, bam, now your “statically typed” language actually has dynamic type checking.
And if you use a “statically typed” language, but have an algebraic type that holds int or string or ClassA or Array, how useful is that static typing? That’s why I prefer calling the code either weekly typed or strongly typed. And some languages allow you to have strongly typed code with ease, and some languages allow you to have weakly typed code with ease, and some, like TypeScript, allow you both in which case the whole dilemma seems unwarranted or at least outdated.
> Ok, so you have ParentClass and ChildClassA and ChildClassB that inherit from it.
Why do you even assume classes are involved?
> And, bam, now your “statically typed” language actually has dynamic type checking.
Not in any way, shape, or form?
You have a ParentClass, it lets you perform ParentClass operations. If you want to do a ChildClassA operation if that's that, you check whether that's what you have, then do the operation. Both are enforced by the compiler (though how safe the check is varies).
If you have a dynamically typed language, you might think you have a ParentClass but you actually have a completely unrelated ToyotaSupra.
> And if you use a “statically typed” language, but have an algebraic type that holds int or string or ClassA or Array, how useful is that static typing?
Extremely. Because you know what the potential is, and you have to check for it exhaustively.
> That’s why I prefer calling the code either weekly typed or strongly typed.
Because classes are almost ubiquitous in modern commercial programming?
> If you want to do a ChildClassA operation if that's that, you check whether that's what you have, then do the operation. Both are enforced by the compiler (though how safe the check is varies).
Go to GitHub and search for "GetType language:csharp" or "getClass language:java" and see millions of hits for runtime checking in “statically typed languages". And who knows how many ad hoc runtime checks there are in C++ and other languages without a default reflection framework.
> If you have a dynamically typed language, you might think you have a ParentClass but you actually have a completely unrelated ToyotaSupra.
So is a dynamic language that supports type annotations in comments (eg JavaScript + JSDoc) actually a statically typed language?
> Extremely. Because you know what the potential is, and you have to check for it exhaustively.
It’s still going to be a mess that should be refactored.
> So complete nonsense to make you feel good?
You can do runtime type checks in “statically typed” languages. You can use type inference in “dynamic” languages. That is just facts.
`dynamic_cast` does a dynamic type cast (a downcast). It does what it says on the tin. It's purpose is to cast a value whose precise type may not be known until runtime.
Yes, downcasting performs dynamic type checking. There are no two ways about it. C++ correctly calls this `dynamic_cast`. The only way to completely avoid dynamic type checking in such object hierarchies is to forbid downcast completely, as OCaml does, for example.
Most "statically typed" OOP languages have this escape hatch for dynamic typing because it is very useful, and is usually only performed after a check for the correct type has been performed. or if it is known by the programmer that the correct type is present, but there is no way to express this knowledge in the type system.
The key problem has been that the resulting programs are fantastically hard to understand. One of the things I think Dijkstra got right in "Goto Considered Harmful" is that humans can reason about programs better when there's a strong correspondence between the textual structure of the code and its execution structure at runtime.
Runtime metaprogramming deliberately upends that.
I think it's probably totally fine to use those kinds of features as debugging aids when hacking on a program and figuring out where stuff is going wrong. That aligns with many of the use cases Hillel lists here. But you probably don't want to commit code like that to the repo and require everyone else maintaining the program to understand it.
For better or worse, we seem to lean strongly towards languages and language features that are used not just during the development process but also make sense in committed, maintained code.
Dynamically typed languages lean in the other direction. They can be very powerful and productive while you're in the middle of working on a program, but the resulting artifact is harder for others to understand and maintain.
Dynamically typed languages to me feel like unbaked plasticine. You can have a lot of fun molding it and you can get it into the shape you want much quicker than you could carving wood or stone. But if a dozen people are collaborating on a sculpture over months or years, it's impossible to build on top of someone else's plasticine part without it shushing down and falling apart.
Or course, the holy grail is baked plasticine: a language that is dynamically typed and moldable while you're figuring stuff out and then can be baked with static types for the parts that are solid and need to be reliable. I've yet to see an optionally or gradually typed language pull that off well. The boundary between them just gets too weird.