Parametricity, or Comptime is Bonkers
54 points by soareschen
54 points by soareschen
A little bit bit facetious, but...
use std::any::type_name;
use std::mem;
fn mystery<T>(a: T) -> T {
match type_name::<T>() {
"i32" => {
let int: i32 = unsafe { mem::transmute_copy(&a) };
unsafe { mem::transmute_copy(&(int + 1)) }
}
"bool" => {
let b: bool = unsafe { mem::transmute_copy(&a) };
unsafe { mem::transmute_copy(&!b) }
}
_ => a
}
}
pub fn main() -> () {
println!("{}", mystery(1.0));
println!("{}", mystery(42));
println!("{}", mystery(true));
}
Something I've learned is indeed that every useful type system has an escape hatch somewhere. :D (Something theorists tend to ignore or accept without comment.) The differences are mostly in what you can do without it.
Right? "Type systems give you predictable guarantees about a program" generally omits an extra clause: "...provided the code you're interfacing with isn't being actively adversarial". Op's comment is funny, but if anything it's a good demonstration of the sort of obviously evil lengths one has to go to subvert parametricity in Rust, and how difficult it is to fall into that trap accidentally.
type_name() isn't guaranteed to return different values for different types, so maybe it'd be better to use TypeId (which is what Any uses)
I'm not really a rust expert outside of a few small toy projects (I prefer zig), just something I threw together in five minutes or so to see if could be done easily.
I haven't done much rust, but plenty of haskell. Do rust functions need to be pure? I.E. can't they do side-effects like print string, fire missiles etc.?
No, there's no way (of that I know) to guarantee pureness in rust. 1
But you can, for example, encode in the type signature that the function is not allowed to mutate the parameter.
I remember hearing this should be possible with dyn Any, which would not even need unsafe.
It's not comptime that breaks parametricity in Zig, it's that Zig does not have an equivalent to Rust traits or Haskell typeclasses that would express and limit what you're allowed to do with the comptime type that is passed in. Zig's type manipulation machinery is imperative rather than declarative, but that does not make it incompatible with parametricity. Zig has just chosen not put those restrictions on its comptime types.
Wonder why this comment was not higher ;)
I think Zig should also allow you to write type-type functions (I'm not familiar with Zig, but imagine fun ToString(comptime t: Type): Type), and then use the returned type as a shape. Something like
fun print(comptime t: Type, to_string_impl: ToString(t), value: t).
In this sense Zig is more a dependent type system, which does not break parametricity in any way. Given that many languages also has type reflection tools available as part of the language ("escape-hatch" as I saw another comment call it), I would guess it's more of a culture of structuring the code around specifying the restrictions up front or rely on reflection.
In this sense Zig is more a dependent type system, which does not break parametricity in any way.
Dependent type systems generally limit you to writing code which they can type-check parametrically.
I really don't think it's accurate to call zig's type system parametric when you can write:
fn sus(a: anytype, b: someFunction(@TypeOf(a)) @TypeOf(a) {
return b;
}
This function only typechecks parametrically if forall T. (someFunction(T) == T). Most type systems either restrict what you can put in someFunction, or require you to accompany sus with a proof that forall T. someFunction(T) == T. Zig lets you write arbitrary code in someFunction and then use sus even if does not typecheck for all T, so long as it typechecks for every T that you want to use. That's in practice often very useful, and most of the time I prefer it, but it's definitely not parametric.
This is a really great point and it makes me wish for a language with comptime types and traits. What if... you couldn't switch/match on a comptime type by default?
To me this highlights a difference in philosophy. I think Zig is a language focused on writing software and providing tools for doing so. I think this article is a lot more interested in the abstract than the concrete.
In actual software, the rust mystery function can use unsafe to do crimes with the provided pointer. Zig has no notion of unsafe and so this distinction makes no sense there, and type theory breaks down in the face of unsafe so doesn't even become a consideration if what you are interested in is the type theory and not the actual program.
I think the argument that this is a type system feature that is a positive for making code easier to understand is weak. In practice I find comptime code a lot easier to understand than heavily abstracted rust code, like understanding the types that you end up handling when dealing with tokio-style async code in rust. Zig comptime code is just code, and to understand it you can read it just like any other code. I find the argument in the article to be completely backwards! I prefer reading code to understand it, so make the code as readable as possible. To understand complex types in a powerful type system you need to basically run an interpreter for the type system in your head. I don't find that any easier than just reading the code.
I'm not saying types aren't useful or that Zig doesn't have a powerful type system either. I just think types aren't primarily useful for reading or understanding code. Types are a way to provide information about the code to the compiler so that it in turn can be a more powerful tool in writing a correct program.
I think type classes are powerful, but I have also come to think that it's a power with a very significant downside. Languages that have opted out of them like Zig and Gleam result in programs that, to me at least, are a lot more readable and easier to understand.
I think Zig is a language focused on writing software and providing tools for doing so.
The most powerful programming tool I've ever encountered is Hoogle. For the uninitiated, it's a utility that allows you to search code by its behaviour, instead of by name.
For example, let's say I need to get the list of files in a directory. In Python, I'm pretty sure that I need some function in os, or possibly os.path. In C++, I think that there's something in std::filesystem that'll do this for me. For both these languages, which I write daily for my day job, I know it'll be a dive into the documentation to find what I'm looking for. Probably a bunch of looking for list_files or lsdir or directory_files until I find what I'm looking for. Comparatively, I can search FilePath -> IO [FilePath] in Hoogle and instantly find that listDirectory from the directories module is exactly what I'm looking for, despite not having used the language in years.
I appreciate that reading code is sometimes the fastest way to understand it, but we rarely truly read code. As a personal example from a few years ago, I was dealing with some code that looked like:
def load_config():
config_path = find_config_file()
if !config_path:
config_path = create_default_config_file()
load_config_file(config_path)
On the one hand, that code is a five second read. On the other hand, that five second read does not tell you why the function would occasionally erase the all the user's files. I spent an entire afternoon tearing through the code, looking for the place where there was even a call to delete files. However, if I had had some proper type signatures, I could have found the problem in ten minutes.
For the curious, find_config_file usually returned a str or a Path, but, under proper circumstances, called a function that called a function that called a function that returned an instance of ConfigPath. load_config_file cast its result back into a str, so the difference didn't matter. However, casting ConfigPath to bool called a function that would parse the config file and delete the config file if the parse failed. Except there was a bug and it deleted the entire home directory instead.
I haven’t used Haskell, but isn’t that config loading a counter-example to understanding code from types? The IO monad would be necessary to read the config, and could include deleting the home directory.
You're right that the IO monad would be needed for all three functions, but there's two bits that come into play here. First, the line:
if !config_path
Since it was the casting into bool that actually was deleting the files, the signature would be ConfigPath -> IO Bool instead of ConfigPath -> Bool, which means that the corresponding Haskell would have been
when (not <$> toBool config_path)
instead of
if (not $ toBool config_path)
which would have been a giant beacon indicating that something weird is going on.
Second, this isn't just a Haskell thing. My biggest issue is that it took three hours to find that there was a code path for find_config_file that returned a ConfigPath. I thought that find_config_file only returned a string or Path, which didn't have these weird casting issues. If I'd had a type signature for find_config_file, I would have opened ConfigPath.py three hours sooner and seen the problem immediately.
Edit: fix code formatting
Hoogle is amazing yes! Another fantastic tool from the Haskell ecosystem that I miss elsewhere is Quickcheck.
However, if I had had some proper type signatures, I could have found the problem in ten minutes.
I agree about the value of good type signatures, and Zig has them (and a good LSP which is what most languages go for these days). My problem with things like type classes is that the types often end up being inscrutable, at least in my experience. And that then means that reading the code of the function is not enough, now I have to chase down and understand why the types are what they are.
In actual software, the rust mystery function can use unsafe to do crimes with the provided pointer.
Which is why it's part of a system along with other language features and, if you're concerned about that sort of bad/grey-faith actor, then you structure your code so the unsafe stuff gets a correctness-enforcing wrapper out in its own high-trust module and you put #![forbid(unsafe_code)] at the top of the other modules that accept contributions from the junior team members or PRs from passers-by.
I think you missed my point but sure, I’m not recommending anyone to use unsafe to do crimes, just like I wouldn’t recommend crimes in zig either
As I understand it, the point I was replying to was "The article's claim about being able to reason from the function signature is flawed because unsafe exists" and my response was "Yes, which is why Rust lets you lock out unsafe within scopes of your choice to ensure that property continues to hold."
Naturally, Rust isn't trying to be a security boundary (See Also I-unsound on the bug tracker), but no language can be, given out-of-context problems like twiddling with the contents of /proc/<PID>/mem and, if the "bad faith actors" are really just interns and juniors who might give in to temptation to do a quick and dirty implementation of something, then Rust has the tools to keep that from making code audits a pain and that's the context in which being able to reason from function signatures is intended to be taken.
Right, so that was not quite the point I was trying to make. My point was that the additional ability to reason from the type signature that the article talks about beyond what you get in Zig (which also has types and type signatures), is of limited utility in practice and comes with significant trade-offs in complexity. Besides, as someone else pointed out, as soon as you allow for side effects the type signature tells you even less.
I think we're going to have to agree to disagree on that one. To some extent, it all boils down to "Did the API designer exercise discretion?" in the sense that side-effects within the language are a more minor counterpart to side-effects outside the language, like the aforementioned "open your own /proc/<PID>/mem and monkey-patch memory to bypass the language's access-control model" example.
As long as you're not doing insane "Rust's type system is turing complete" metaprogramming (which I also see as "not exercising discretion"), these type system features are akin to things like "Rust has monadic error handling, not exceptions". They push the common cases in a direction where valuable information is easier to notice and harder to overlook.
Things like side-effects generally don't violate it too badly because the ways you are likely to need to use them which are particularly relevant tend to be stuff like abusing globals/statics, which both feels like a code smell thanks to Rust's Send/Sync bounds (at least, until they hammer through that experiment in Rc/Arc Ergonomics so that "inherently code-smell-y but we haven't invented anything better" architectures can get doused in air freshener) and has a long history of "In hindsight, this libc design decision was a mistake" for all but the most novice programmers to likely have learned of at least once.
Well, I don't know if we're really disagreeing as much as talking past each other, I think I broadly agree with what you are saying.
I think parametricity one of those things that PL nerds love to talk about, but no one else cares about it. It's also easily broken in most (if not all) languages as all you need is runtime type checks, which is a useful feature and most languages provide a way to do it: it's already standard in OOP languages, Haskell has Typeable, Rust has Any, ...
Typeable and Any allow the programmer to selectively and explicitly opt out of parametricity, they don’t break parametricity.
How is adding Typeable/Any selectively and explicitly opting out, but using adding comptime isn't?
Rust generics don't carry the Any type bound by default, but there's no way in Zig (that I know of) to do generics without bringing in the full power of comptime.
There’s a distinction between parametric type parameters which have no trait or typeclass bounds, and types with ad-hoc polymorphism that do have bounds. You still get the modularity guarantees of parametricity when the run-time typing bounds are missing. In C++ and Zig there’s no indication in a function’s type signature whether it inspects a type parameter or not.
It makes little difference in practice as I'm can just call a weird parametricity-breaking function both in Haskell, Rust, OOP langauges, Zig the same way in a call site where the type argument is known enough to resolve the trait/typeclass predicate/constraint.
Yeah there can be other things that break parametricity but by themselves Typeable/Any do not. AFAIK there isn’t a way to break parametricity in Haskell because ghc’s IR is typed and parametric, and there isn’t enough information in the runtime heap layout to recover types even if you go behind the back of the compiler.
Dunno, n=1 but I think this is a huge part of why I tend to find Rust so much more readable than (for example) Go or C#.
Yes, there's Any but in practice it tends to be used much less than reflection. Both because of culture, and because it's much more limited in scope (you can only test for concrete types, not trait membership). And, because it is a trait that you need to ask for… any code using it stands out with a clear "weird stuff is going on here" smell.
Yeah, I agree. Parametricity is a really nice tool to clearly describe the interface boundary between two chunks of code. If I see a <T>, then I can assume that the code is designed to work for any T. That way, even if I can see that the code is only ever actually used with one specific T, I know that I can modify that T freely from the caller side, and that it won't break the function internals.
That said, I write a lot of TypeScript which has full runtime reflection, and to a certain extent some compile-time reflection, so seeing <T> doesn't guarantee this property, just highly hints at it. I can imagine the same is true for a lot of Zig code, where comptime switching on a type parameter is generally rare.
comptime switching on a type parameter is generally rare
Yeah, it's pretty rare to export a function that doesn't work for all T.
I do often write such functions internally though. Often in places where in rust I'd either duplicate the code, or sigh and reach for a macro. Eg https://github.com/jamii/zest/blob/main/lib/zest.zig#L173-L176 would be a pain to macro-ize in rust - I'd probably just write the function three times and explicitly list all the begin/end tags in each.
Parametricity is also one of the things that make crates so easy to pull into an existing project and use them with zero issues. The author of a library is forced to clearly define the parameters of each function. If you don't properly define them, you can't write the function. That's what Rust is all about: making things explicit.
Standard ML has nothing like Haskell's Typeable or Rust's Any, and that's a good thing!
You can use exceptions as the underlying representation of a universal type, but it's fundamentally different from a Haskell existential wrapper around Typeable in a very important way:
In Haskell, every type has a unique, global, canonical Typeable instance.
In SML, you must explicitly and manually construct the injections and projections into/from your universal type. These injections and projections have a bounded scope like any other SML values. And every time you construct an injection-projection pair, it's different from all previous ones.
For example, if foo_inj and bar_inj are different injections int -> univ, and foo_proj and bar_proj are the corresponding projections univ -> int option, then bar_proj (foo_inj 42) evaluates to NONE, even though foo_proj (foo_inj 42) and bar_proj (bar_inj 42) would both evaluate to SOME 42.
Using such universal types is still a very bad idea in general, but at least SML limits the amount of damage to modularity that you can inflict with them.
It's also easily broken ... Haskell has Typeable
Is what sense do you mean that Typeable breaks parametricity?
If you know a little type theory, you might already see it: this function must return a. Not by convention, nor by style guide: the type system makes any other implementation impossible. There is nothing else you can do with a value of an unknown type except give it back.
What? You can return any value that is of type T so any operation that would be op(a: T,...) -> T would be valid, no? I don't see how it follows that the only valid operation is the identity function.
Parametricity directly attacks this cost. When a function is parametric its type signature is not just a hint about the implementation, but a language-enforced constraint on what the function can possibly do. We don't need to read the body, check the tests, or trust that the name is accurate, to understand properties of the function. The types are a proof, which gives theorems for free1.
Can't disagree more, you can have a contract about the kind of output the function will produce but you still don't know any of the specific which might or might not be relevant, in doubt you need to check anyways.
Edit: Ok everyone, thanks for the answers! Seems like I'm too Python-brained because taking a type T clearly has a different meaning in Python (and some other languages with generics).
What? You can return any value that is of type T so any operation that would be op(a: T,...) -> T would be valid, no? I don't see how it follows that the only valid operation is the identity function.
But it is a completely generic T so you cannot do any operations on it. You can't construct a fresh T object, you can't modify the given ones because you don't know what sort of operations you have available.
That depends on the primitives available in your language though. Eg in go:
func id[T any](x T) T {
var y T
return y
}
This returns the zero value for any T. It is parametric, but the universe of types in go has more parametric functions than just the identity.
Edit: Ok everyone, thanks for the answers! Seems like I'm too Python-brained because taking a type T clearly has a different meaning in Python (and some other languages with generics).
We're starting to feel this pain in Python, too, in fact. In Python, you will often take in a type parameter so that you can construct an instance of it:
class C:
def __init__(self, x: int): ...
def foo(t: type[C]) -> C:
return t(7)
Very similar to the Zig example from OP, but executed at runtime.
The issue is that there's no guarantee that every subclass of C will have a compatible constructor signature. In fact, it's very very common for subclasses to have different constructor signatures, since you almost certainly need additional parameters to initialize whatever state the subclass adds. But that breaks foo!
class D(C):
def __init__(self, x: int, y: str): ...
# BOOM, raises a TypeError!
foo(D)
People have proposed several ways to fix this, but one of the easiest is to recognize that your function doesn't actually need to take in a type, it needs to take in a factory function that records specifically how you want to call it:
def bar(t: Callable[[int], C]) -> C:
return t(7)
Python classes are callable, and so you can still pass in C, just like with foo. But a type checker can more easily see that D is not a valid argument, without having to change the rules about what type[C] means or whether you're allowed to have incompatible constructors in your subclasses:
# ok
bar(C)
# still raises TypeError, but a static type checker
# can detect that more easily
bar(D)
What? You can return any value that is of type T so any operation that would be op(a: T,...) -> T would be valid, no? I don't see how it follows that the only valid operation is the identity function.
The function has to valid for all T. You know nothing about T, other than that you have a value a that is of type T. So the only way to get a value of type T to return is to use the one that was given to you.
If you don't know anything about T then the only valid T that you can produce is a value that you were given. And the only value you were given is… a. (Or crashing.)
You can still have side effects, yes (the saying comes from the Haskell world where they're much more limited too), but similar ideas tend to translate decently well even there. If you don't know anything about T then you can't use anything about a to inform the side effect either, so why are you taking it at all?
any operation that would be op(a: T,...) -> T would be valid
Well, what operations can those other operations do to an arbitrary T with no bounds?
In new(ish) Python (newer than 3.5?), this would be something like
def mystery[T](arg: T) -> T:
# What can we do here except return the arg?
return arg
mystery(1)
mystery("test")
There are some standard library functions that can be used (copy.copy or setattr), but those are already "reflection-like" in a way.
Good post. Staged compilation techniques like Zig's comptime are essentially just a beefed up macro system at heart.
Just looking at the way they handle simple generic functions doesn't really scratch the surface of how they are different. For instance, type classes give you sort of a query system over your types. The engine powering type class resolution is literally a specialized form of a logic engine. This lets type classes form properties that you can use to guarantee things about your type. When your type has Ord, it's not just that the type contains a compare function, but that the comparison has some guarantees about transitively, etc. this info is penetrated deeply by the logic engine and can be used to discover facts about types that aren't directly related.
Staged compilation based macros are also useful, but don't have this logic engine shaped query system. Zig's comptime is more expressive than what normal macro systems provide since it actually gives you some more ability to ask the compiler for info about the type, but those queries are still very surface level at best. You can't ask if a type has some property (exhibits a type class), only if it has some function with a certain name.
This is the level of difference that strong type systems buy you. If you're not really using type classes for these guarantees about types, comptime may be equivalent for your use case.
Comptime is also less expressive than macros in some important ways. It can't change the evaluation order. It can't see expressions, only values. You can (almost) read zig functions as if they were written entirely in a strictly-evaluated dynamically-typed language with no staging. The staging only restricts you to the subset of those dynamically-typed programs that can be compiled efficiently.
Almost, because comptime zig is actually a slightly different dialect eg this works in comptime but is UB at runtime:
fn foo() *usize {
var x: usize = 0;
return &x;
}
Metaprogramming, no matter how convenient, refined, or powerful, is almost always reducing code legibility.
C macros, C++ templates, and now Zig comptime and others (Jai has some flavor of advanced metaprogramming.
In some way, the power and appeal of older languages such as Lisp or Forth were tied to their metaprogramming-friendly designs.
But what happened with the code bases written that way is that they were highly specialized for one user, and this is rarely a desirable feature for large projects.
Ease of maintenance, and thus code legibility, is a lot more important than "power."
At this stage, my take on metaprogramming is that it should be kept out of the language. Writing codegen implies more friction and potential for headaches, but a lot less work for the compiler, and the output code is still vanilla.
I didn't find the first couple of example compelling. Those functions are quite useless (one is just a wrapper and the other is just returning a parameter). Sure you can infer the implementation from the type but so what? You can also just trivially read the body and infer the semantics.
The main issue with elaborate type systems is you often end up with two different models you have to keep in your head: the type-system semantics and the runtime semantics.
In practice it doesn't matter what the type-system is – the only reason it's there is to help identify and curb problematic runtime behavior. I find type-systems heavy PLs forget this and go overboard on type-system complexity as an end in itself. We should focus on what problems we want to avoid in writing and running the code and there are multiple approaches to this end. Zig is refreshing in that I find there is only one "execution model". The type model is so close to actual runtime with comptime that it makes reasoning with types align nicely with following the code execution.
These are the types of examples I would love to be able to compare to jai because i know its metaprogramming is even more bonkers. Hopefully a public beta will be out soon.
Non-parametricity works for types, because in most programs each function is only applied to a finite and manageably small number of types. You can just check them all individually.
But the same isn't true of lifetimes. If you wanted a zig-like type system but also rust-like lifetimes, I think you need to add opt-in parametricity. Perhaps declaring an argument parametric means throwing a comptime error if you try to do anything with that argument other than passing it to a few blessed unification-style functions. 1ML has similar restrictions on => function arguments.