.hs-boot files “solve” cyclic-dependencies
https://wiki.haskell.org/Mutually_recursive_modules
What is the current wisdom on whether to use them or not?
If they are used, what kinds of problems do they introduce?
.hs-boot files “solve” cyclic-dependencies
https://wiki.haskell.org/Mutually_recursive_modules
What is the current wisdom on whether to use them or not?
If they are used, what kinds of problems do they introduce?
You need to keep Module.hs
and Module.hs-boot
in sync. This might be bothersome if you are not yet settled on a design and are modifying small things.
They are not first class citizens, so there are some GHC limitations (you can’t use families inside them), some malformed .hs-boot
modules will cause GHC to crash or emit unuseful errors.
Same thing for other Haskell tools like cabal and I suppose HLS, there are some small nitpicks especially in error reporting which haven’t been ironed out yet.
You decide whether it is worth the gain.
If creating a more organized module structure solves the problem, do that.
If not (or you just can’t be bothered) it’s not a faux-pas to use .hs-boot
files or anything, so go ahead.
As @f-a mentioned, you might encounter some annoyances if you do, but as long as you use it sparingly, it shouldn’t be too much of a problem. (And if it does become a problem, then reorganize your modules so you don’t need the .hs-boot
files )
If you have cyclic dependencies, you have an architectural problem. Most organisations would absolutely reject a PR that introduces hs-boot, because it increases the complexity and is a poisoned gift for the next engineers who have to make the codebase evolve.
It’s better to tackle the root cause of a cycle early on.
I agree that some (most?) cyclical module imports are caused by not-ideal module hierarchies, and the solution there is to re-think that structure.
I disagree with “cyclical modules are evil”. Some well-designed structures use module dependency cycles reasonably, and hs-boot modules are perfectly suited for breaking those cycles to please the compiler.
For instance, I’d rather avoid shoving every type into a single Types
module or module hierarchy. Or having 4000 line modules of mixed domains just to avoid the cycle. That is, I don’t think “avoid cycles at all costs” is necessarily constructive.
Despite some limitations mentioned by others (e.g. lack of something regarding families), hs-boot modules are battle tested. For the majority of cycle-breaking use cases they should fit the bill without issue.
I think this is a bit hyperbolic. I think the claim about most organizations rejecting such PRs is also false; mine would not
In particular, I find the following line of thought helpful: within a module, we have unrestricted mutual recursion between all declarations, and nobody finds this strange or concerning. If you chop such a module into several modules, you might now have multiple modules with mutual recursion between them, but the structure of the actual definitions is identical. I claim that if it is architecturally problematic afterwards then it was architecturally problematic before… and yet I have never heard anyone argue that complex cyclic dependencies within a module are bad
I do agree that it can make the module structure harder to keep in your head if abused, but changing the module structure to avoid cycles can also make it harder to comprehend. “Why is this utility function on Foo
in a separate module by itself instead of next to the definition of Foo
? Ah, because otherwise it would make a module cycle…”
It’s not actually conceptually very hard to say what happens in a program with cyclic module dependencies (again, GHC can do it fine within a single module). I worked on a programming language that had unrestricted cyclic module dependencies, and it was fine.
Note also that mutual recursion across modules is common in languages such as Java or C#. I think it makes great sense not wanting to think about mutual recursion; you can still use a tool to visualise dependency SCCs.
C# goes even further and provides the ability to split individual classes and even methods over multiple files: Partial Classes and Methods - C# Programming Guide - C# | Microsoft Learn. As the docs say, this is beneficial for code gen tools; very pragmatic.
Really, what’s problematic about mutually recursive modules in GHC is that we have to write these dreadful .hs-boot files rather than letting the compiler infer them for us. This is the corresponding GHC issue to fix this: #1409: Allow recursively dependent modules transparently (without .hs-boot or anything) · Issues · Glasgow Haskell Compiler / GHC · GitLab.
hs boot files are fine. They clearly show what parts are “shared”.
I sometimes end up with them if:
Types.hs
Utility.hs
moduleThere are two ways to solve this:
Mushing everything into an internal module just destroyed your code architecture. Using orphan instances is debatable too.
Still… doing hs boot files often/prematurely may be a sign of bad code architecture. It’s a good exercise to try to solve the cyclic imports in another fashion. But if you find that really takes tremendous effort or leads to weird code structure, just stop.
There are also other ways you can end up with cyclic issues: if you want a set of functions in a separate module for “visibility” reasons, like grouping unsafe stuff. That won’t necessarily follow traditional tree hierarchies.
I think the main issue is ergonomics: many Haskell users don’t know how to exactly use hs boot files to break cycles. If you’ve done that a few times, it doesn’t seem very hard, but it can indeed be confusing to do manually. Why does the compiler not do it for us automatically?
See When is a module too big? When is a module too small? - #5 by chreekat for an earlier discussion on this topic. I was also on team What’s-the-big-deal-with-boot-files. I still am, I guess, but I understand that they may represent a code smell.