Swift Regret: Top-Level Decls in Script Mode
Swift regret: cross-file access to let/var in “top-level code”.
— Jordan Rose (@UINT_MIN) October 20, 2021
This one’s going to take a bit to explain but I think it’s got an interesting analysis behind it. There’s a tension between two different design choices to make the language more convenient.
Part of the Swift Regrets series.
This one’s going to take a bit to explain but I think it’s got an interesting analysis behind it. There’s a tension between two different design choices to make the language more convenient.
The issue itself isn’t super complex. If you have a module with multiple files and one of them has top-level code (main.swift), let/var decls in that code are treated like globals, which means they can be accessed from other files. However…normal globals are initialized lazily on first access. let/var in top-level code are initialized eagerly, like locals. (This is so that side effects happen in order as the variables get initialized.) Combine these two facts and you get SR-3316. A rather silly way to break memory safety…
So, what’s the correct design? We could say top-level let/var should be lazy like globals usually are, but I think that’d be really confusing. Imagine if local variables worked like that.
The other option is to say top-level let/var shouldn’t be accessible from other files. But that doesn’t totally save us: what about top-level functions that use the let/var? They have the same problem.
So why are these decls accessible? There’s another rule in play here: the default access level is internal
. If it were fileprivate
, you wouldn’t get into this state, at least not by accident. And also if there were no top-level code in play, you wouldn’t get into this state.
Both of those features are things we categorize as forms of “progressive disclosure”, though I think in practice it’s more like “progressive rigor”. internal
is the default so that you can whip up an app/executable target without bothering with access control if you want. Similarly, top-level code exists so that you can have everything in one file, only breaking it out into multiple files when you’re ready. And then you can move just some of the decls and leave others. Both of these are meant to make simple projects scale more smoothly into complex ones. But they collide unfortunately here.
The solution I originally wanted is to say “decls in top-level code are not globals by default”. If you explicitly mark one with access, it’s not allowed to capture the regular local-style let/var bindings, because initialization order can’t be enforced. But now I think it’d be simpler to say “they’re not available in other files, period”. The things you want to break out into other files are usually helper functions anyway; the top-level logic is always going to stay in main.swift by necessity. It’d be a breaking change—people have run into the SR, after all—but it could still be worth it as long as it’s restricted by language mode (-swift-version
). Most people would never know the difference, doubly so because it doesn’t affect libraries, playgrounds, or @main
.
Fortunately, Evan Wilde is looking into changing this in Swift 6! So maybe things will get better in one way or another.