As the person who was the inspiration for this proposal, I think it has promise, but as presented I don’t think it passes the bar for getting hard-coded into the language. With more work, it could be great, but it definitely needs more work.
Extensibility
All the problems basically boil down to extensibility:
- People have expressed concerns about how argument parsing works
- People have expressed concerns about positional vs named parameters
- I will chip with a few more requirements: what about
--help
text generation and formatting? What about bash autocomplete?
- Ammonite’s version of this feature allows (A) multiple main methods per program and (B) allows passing in arguments by their
--name
- The current proposal is very close, but not quite, enough to satisfy use cases in Mill and Cask which do basically the same thing (in fact, those implementations are all copy-pasted from Ammonite!)
The basic issue is that the compiler should not be dictating parameter parsing for the entire Scala ecosystem with so little thought. If we have a deep discussion and wide consensus on how parameter parsing should look like across the community and have a complete and holistic implementation, then I can accept hard-coding it forever in the compiler. But we don’t!
The sensible thing in this scenario is to make it properly extensible. The compiler can still provide a default, but people should be able to hook in their own logic where necessary to satisfy their own use cases. This will also allow the state of the art to improve over time, rather than having a half-baked implementation set in stone forever. The community can develop their own logic for --help
message generation, bash autocomplete, --keyword
params, and so on.
Requirements
I have experience implementing similar features in four separate places: Autowire, Ammonite, Mill, and Cask. What’s shared between them? The shared logic is essentially:
- Resolve a type-class for handling the method return value, and method name
- Resolve a (potentially different) type-class for parsing every parameter, and store its name, and default value
- We need to store the resolved method/parameter metadata somewhere
We can see this in the following existing implementations:
Once we have this metadata, the remaining handling can be done in user-land code: whether serving a HTTP endpoint, a main method, or a Mill command-line command.
How To Fix This
The simplest way to fix the current proposal to make it satisfy these requirements is to do two things:
-
Split up the compiler-level feature from the user-land implementation code. There are three compiler-level features here:
- Resolving typeclasses and other metadata for methods, arguments, and return values
- Storing the metadata somewhere
- Synthesizing a wrapper class and main method that makes use of that metadata
-
Make the user-land implementation code swappable
Resolving Typeclasses
To make the typeclass resolution logic swappable, we could turn @main
from a hardcoded compiler-level annotation to instead support any annotation class that implements a trait. This could be something like:
trait main extends MainMethod[FromString, DummyImplicit]
trait MainMethod[ArgHandler[_], ReturnHandler[_]] extends StaticAnnotation{
def visitMethod[T: ReturnHandler](name: String): Unit
def visitArg[T: ArgHandler](name: String, default: => T): Unit
def visitMethodEnd(): Unit
}
Above I have described a Visitor-pattern API, but we could easily use whatever API style people would prefer. What’s important is that if someone wants to change the typeclass resolution logic, e.g. if we wanted to make our main method take JSON instead of positional parameters, we can do so. (This is not a hypothetical: I do this right now in Cask!)
Storing Metadata
The above visitor-based definition is sufficient to also store the metadata: the visitFoo
methods return Unit
and are expected to side effect. If the MainMethod
trait instantiated as a side-effecting statement right above where the method is defined in the source code, it can then store its metadata whereever the implementor chooses.
Note that while the above specification relies on side effects and the visitor pattern, it is trivial to come up with a specification that works more “purely” by having the MainMethod
interface methods return the metadata we care about as a value.
Constructing the Wrapper Class and main method
The above two sections Resolving Typeclasses
and Storing Metadata
is sufficient for all my use cases in Cask, Mill, and Ammonite: they each have their own launcher code that can inspect the metadata and act accordingly. However, if we make the wrapper class and main method configurable, we could dispense with the custom launchers entirely and converge those implementations closer to plain-old-Scala-programs. The easiest thing would be a change like the following:
trait main extends MainMethod[FromString, DummyImplicit]
trait MainMethod[ArgHandler[_], ReturnHandler[_]] extends StaticAnnotation{
def visitMethod[T: ReturnHandler](name: String): Unit
def visitArg[T: ArgHandler](name: String, default: => T): Unit
def visitMethodEnd(): Unit
+ def main0(args: Array[String]): Unit
}
We could specify that the wrapper class always has a def main(args: Array[String]): Unit
entrypoint method that calls new MyMainMethodAnnotationClass{}.main0(args)
. The official @main
entrypoint could then do parameter parsing using the stored method/param metadata via a simple/naive positional approach, but the community could easily override it to do parameter parsing in other ways: adding support for --help
text or --keyword
params, Ammonite could plug in support for multiple main methods with --keyword
params and default values, Cask could make def main0
start the HTTP server and use the metadata for routing, and so on.
Note that the interfaces proposed in this post are rough sketches, so they may have holes and be incomplete. You’ll have to trust me when I say that they can be made to work, since I maintain exactly such interfaces in three widely-used applications.
Conclusion
Overall, I think the idea is a good one, but I do not think the current proposal passes the bar: I think it is too narrow and too incomplete to be worthy of including in the Scala standard library, where once the user passes “hello world” they will find it immediately inadequate and need to discard it. This risks it becoming a “good for slides and tutorials and nothing else” feature which we have to warn people against using: scala.util.parsing all over again.
However, with a bit of extensibility, I think all the concerns can be solved: we just need typeclass resolution and the runtime def main
argument parsing to be swappable. Then it doesn’t matter how incomplete the built-in standard library @main
is: people who want to use the feature for more realistic workloads will be able to extend it to provide the functionality they need, while at the same time standardizing the whole community on a style of defining program entrypoints that is common throughout a myriad of application domains.
We don’t need to support every use case out of the box, e.g. Cask’s composable/stackable annotations which are very cool are probably out of scope. But we should aim to provide a language feature that can scale to support a developer as they grow throughout their career, and not just at the start. After all, being a “scalable language” is what Scala is all about.