Jam Programming Language
32 points by bittermandel
32 points by bittermandel
Trait solving is a search problem. Borrow checking is a whole-function region analysis. Monomorphization multiplies the code volume before LLVM, the slowest stage, even sees it. None of this is waste from Rust’s point of view; each pass buys a guarantee. But you pay for all of them on every build.
[...]
The analyses Rust spreads across HIR and MIR, the drop placement, the init-before-use check, and the call-site exclusivity rule from the earlier sections, all run as local dataflow passes over JIR rather than as a whole-program lifetime-and-region inference. And because Jam annotates a type at every binding, the front end does far less of the global type inference and open-ended trait search that dominate rustc’s middle.
This LLM-generated writing is doing something I think engineers, especially younger engineers, should be wary of: substituting qualitative/evocative prose in lieu of quantitative data. Persuasion via storytelling is easier (for both author and audience) than going through the hassle of collecting and analyzing hard numbers. Human brains love stories, and stories work best when they're simple and clean. Real world data often reflects a complicated world filled with as much nuance as you care to look for.
Compare and contrast this quantitative blog post profiling the Rust compiler by a rustc contributor.
*nod* One of my longer-term projects is something I'm calling "Millennium Universal Native HIG" (i.e. a guide to what Windows, Mac OS, KDE, GNOME, etc. seemed to be converging toward before ~2001 brought Mac OS X's laissez-faire attitude toward its own HIG, mobile convergence, and webtech UIs.) and one of the big things I've been doing to prepare for it is tracking down all sorts of source material so I can do the equivalent due diligence in contexts where either numbers aren't available or wouldn't be the correct way to convince the target audience.
(eg. Design element such-and-such is still a good idea because of these reasons and my source is user testing session X of the Apple Lisa's development cycle where they tried it against designs Y and Z.)
At the moment, aside from the pile of dead-tree references I've acquired (eg. Bootstrapping by Thierry Bardini, various 80s and 90s platform HIGs, etc.), I'm currently using https://www.folklore.org/ as my bathroom reader to trawl through for stuff relevant to the project.
Not everything is best served by showing numbers, but everything has a proper way to do the research.
substituting qualitative/evocative prose in lieu of quantitative data.
I really enjoyed your phrasing here (and this comment in general), with this point sticking out to me most: good technical writing can (and should, where appropriate) include both, but don't miss out on what you really need to get across. I think running large teams of assurance folks has taught me how hard technical writing really is, and I think, to your point, we really need to be wary of how much worse this can get with easy access to LLMs.
Is the primary difference from Zig the presence of drop and absence of specific easily misused constructs (undefined)?
Maybe(T)is a regular generic struct with three operations:empty()constructs a slot whose contents are not yet meaningful,write()fills it, andunsafeAssumeInit()extracts the value. The naming carries the safety story. Every consume site contains the wordunsafe, so reviewers (human and AI) can grep for it and find every place a runtime invariant is being asserted.
OK so there's no undefined and every value must be initialized, but also Maybe(T).empty() returns something with contents that are "not yet meaningful", and presumably immediately following that a unsafeAssumeInit() would return garbage. So it's not "safe" in the sense that Rust is, where the compiler treats unsafe as a taint that requires an explicit unsafe { .. } for un-taint.
This is the code sample used to indicate safety and drop functionality:
const File = struct {
fd: i32,
fn drop(self: mut File) {
close(self.fd);
}
};
export fn useFile() i32 {
const f: File = { fd: 7 };
return f.fd;
}
Do I have it wrong, or is this unsafe? Ignoring the manual file descriptor assignment, it's going to call close(7) and then return 7 -- there's no lifetime tracking so no way for the user to express that the lifetime of the file descriptor ended before useFile() returned.
Jam doesn’t have this seam, because the thing that makes Rust’s ABI unstable doesn’t exist in Jam. There are no first-class references, no lifetimes, no niche-packed layouts to erase. A Jam value is value-shaped all the way down, the same property that made the collection APIs by-value earlier, so a Jam struct already has a C-compatible layout. There is nothing to translate.
In the given example, where export fn counterAdd(c: mut Counter, n: i64) i64 { .. } becomes int64_t counterAdd(Counter *c, int64_t n);, how do you express whether c is allowed to be NULL or not? Rust has a well-defined ABI for that, you can have extern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64 or extern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64.
No
reprannotation, no raw-pointer cast, and crucially nounsafe.
But the Rust version doesn't need to have unsafe either. You can define the API with references. Ironically the only place where unsafe might be needed is for #[no_mangle] which is #[unsafe(no_mangle)] in more recent Rust, but the example constructs Rust that uses raw pointers for some reason.
Later on it gives this example:
extern fn snprintf(buf: *mut[] u8, size: u64, fmt: *const[] u8, ...) i32;
fn render(value: i32) i32 {
var buf: [16]u8 = [0; 16];
return snprintf(&buf[0], 16, "n=%d", value);
}
Shouldn't there be an unsafe there? Somewhere? snprintf is taking raw pointers so per the earlier guidance about unsafe operations being discoverable by name, it should be unsafeSnprintf with some sort of symbol override.
One honest caveat: at the
externline you are talking to C, and C’s rules win.
Do I have it wrong, or is this unsafe? Ignoring the manual file descriptor assignment, it's going to call
close(7)and then return7-- there's no lifetime tracking so no way for the user to express that the lifetime of the file descriptor ended beforeuseFile()returned.
That's my reading as well. It's no different to .as_raw_fd() in rust though, which has the same safety issue.
It is different: .as_raw_fd doesn't close the file, it just doesn't prevent you from closing the file.
That's not what I was saying. I meant that the code was equivalent to this safe rust:
pub fn use_file() -> i32 {
let f: File = ...;
return f.as_raw_fd();
}
f gets closed at the end of the scope in both languages.
the thing that makes Rust’s ABI unstable doesn’t exist in Jam. There are no first-class references, no lifetimes, no niche-packed layouts to erase.
This is a misunderstanding of Rust's standard library FFI-stability. Shared references, mutable references, Box, and Options thereof all have a defined, stable ABI (making the whole Box::into_raw/from_raw dance of the example pointless). Lifetimes don't even exist at the binary level. If you opt into defining a stable ABI for an enum, niche optimizations get disabled.
The reason that most types choose not to define a stable ABI is that you often don't want to have a stable ABI, as that prevents you from changing the type's internals.
Jam isn’t public yet. The compiler exists and runs, but I’m holding the language back from a wider release while I work on the things that make it usable day to day: a stable surface, a package manager, an LSP, a formatter, the rest of the tooling you only notice when it isn’t there. Shipping a language without that is shipping a sharp edge, and I’d rather take the time.
I don't understand this choice - there's a big difference between "shipping" something incomplete and just making the source available. If you're going to do it eventually, what's the harm in doing it while the project is being built? The upsides are that people who really like what you're doing can try it and potentially even contribute (but "in the age of AI" I know it's not clear that such contributions will be net positive.) It also enables people to better understand what you're building and evaluate claims about why it's great. If we can't do that the project becomes a lot less interesting.
Also should point out that people exist who use none of these tools (yes, my current team can't even agree to adopt an automated formatter, but is otherwise great) so holding back while you build them doesn't make any difference.
None of this exposes a first-class reference. There is no reference type in Jam: no value the borrow could attach to, nothing to store in a variable or return from a function or hold in a struct field. Parameter borrows are call-frame ephemera. The callee gets read or read-write access through the parameter, the access expires when the call returns, and that’s it. Jam doesn’t need lifetime annotations to make any of it safe, because there are no lifetimes to attach.
People keep trying to invent "Rust, without those pesky lifetimes!" and keep failing. The other comment covers one failure mode (part of dropped value returned because you can't return references), but the other classic one is:
let mut arr = vec![1];
let x = &arr[0];
arr.push(2);
// What happens if you use `x`?
There are 3 answers:
There are good reasons to choose any of these answers, but I get the sense that Jam wants to be (1) like Rust, but is actually (2) due to its value semantics (everything gets copied). This probably prevents you from both writing data structures that are both safe & efficient.
People keep trying to invent "Rust, without those pesky lifetimes!" and keep failing.
Inko does a pretty good job I'd say (ignoring my obvious bias), but it certainly isn't without its own trade-offs. Most notably, when you throw out the borrow checker it becomes a lot more difficult to support stack allocated types without introducing a bunch of caveats (e.g. copying them upon borrowing them, which is what both Inko and Swift do).
due to its value semantics (everything gets copied).
Not sure about Jam, but note that Hylo's flavor of mutable value semantics actually have a form of borrow called subscripts. So it's more of a middle ground.
I think one way to understand Hylo is to imagine that it has rust-like &/&mut, but they can never be placed inside another value, so lifetimes never appear in types. That also avoids most of the need to specify lifetimes in function signatures (the remaining cases are just unwriteable). C# refs and OxCaml locals are in a similar place. It's not bad company to be in.
When I read that, my first question was "how do you store a reference in a struct if you have neither references nor lifetime annotations?" Looking at the language reference, while there aren't references, there are pointers (both mut and const), and I couldn't find anything about the safety of those.
I agree with your analysis, but I don't agree with the implication that people should stop trying to have the cake and eat it too. I encourage it, because lifetimes are indeed a syntactical mess when you're trying to do tricky things. If there's a better way, I want people to find it.
Love new languages, but hate everything just being a frontend for llvm, i get backends are hard, but lets have at least SOME other choices some times
A major part of what makes Zig what it is is the lack of RAII, and likewise Rust is borrow checking, and where these design choices landed doesn't feel like something anyone actually needs (RAII without references). That said, I think there is space for and appreciate experimenting in this niche, I just don’t think this approach is it.
I'll throw what I have been considering, because it's an area I am thinking about constantly lately. I am hopeful about some combination of a Zig comptime, Pony-ish reference capabilities, lifetimes are comptime values, and lifetimes branded to the allocator.. the hope being Zig's allocator strategy with the reference safety, and lifetimes that you basically never have to annotate
The counter comparison between Rust and Jam in the “Safe, and C-ABI compatible” section is quite unfair:
The Rust code boxes the counter and has Rust own the memory and so requires counter_new and counter_free. The Jam code doesn’t box the counter, so the caller could keep it on the stack or use malloc/free at its choice. Rust would have been perfectly happy with that too (though I will freely admit that, as things grow, Rust’s aliasing guarantees will be easier to violate this way).
The Rust code squeezes the add functionality into counter_add. The Jam code uses an add method so that the export fn is purely a stub.
Here’s something balancing them out:
#[repr(C)]
pub struct Counter { value: i64 }
impl Counter {
fn add(&mut self, n: i64) -> i64 {
self.value += n;
self.value
}
}
#[no_mangle]
pub unsafe extern "C" fn counter_add(c: *mut Counter, n: i64) -> i64 {
(&mut *c).add(n)
}
const Counter = struct {
value: i64,
fn add(self: mut Counter, n: i64) i64 {
self.value = self.value + n;
return self.value;
}
};
export fn counterAdd(c: mut Counter, n: i64) i64 {
return c.add(n);
}