Description
Scala has done quite well so far without any preprocessor but in some situations it would be quite handy to just drop an #ifdef
or #include
into the source code. Let's resist this temptation (of using cpp) and focus instead on solving the actual problems that we have without adding too much complexity.
Goals
- Conditional compilation which is more fine-grained than conditional source files.
- Well integrated into the compiler: No change to build toolchains required. Positions work normally.
Non-goals
- Lexical macros
- Template expansion
- Advanced predicate language
Status quo in Scala
- Conditional source files
- Code generation
- Various code generation tools in use: Plain Scala code, FMPP, M4, etc.
- https://github.com/sbt/sbt-buildinfo as a lightweight alternative for getting config values into source code
All of these require build tool support. Conditional source files are supported out of the box (for simple cross-versioning in sbt) or relatively easy to add manually. sbt-buildinfo is also ready to use. Code generation is more difficult to implement. Different projects use various ad-how solutions.
Conditional compilation in other languages
C
Using the C preprocessor (cpp):
- Powerful
- Low-level
- Error-prone (macro expansion, hygiene)
- Solves many problems (badly) that Scala doesn't have (e.g. imports, macros)
HTML
- Allows simple conditional processing
- Dangerous errors possible when not supported by tooling (because it appears to be backwards compatible but is really not)
Rust
Built-in conditional compilation:
- Predicates are limited to key==value checks, exists(key), any(ps), all(ps), not(p)
- Configuration options set by the build system (some automatically, like platform and version, others user-definable)
- Keys are not unique (i.e. every key is associated with a set of values)
- 3 ways of conditional compilation:
cfg attribute
(annotation in Scala) allowed where other attributes are allowedcfg_attr
generated attributes conditionallycfg
macro includes config values in the source code
- Syntactic processing: Excluded source code must be parseable
Java
- No preprocessor or conditional compilation support
static final boolean
flags can be used for conditional compilation of well-typed code- Various preprocessing hacks based on preprocessor tools or conditional comments are used in practice
Haskell
Conditional compilation is supported by Cabal:
- Using cpp with macros provided by Cabal for version-specific compilation
Design space
At which level should conditional compilation work?
-
Before parsing: This keeps the config language separate from Scala. It is the most powerful option that allows arbitrary pieces of source code to be made conditional (or replaced by config values) but it is also difficult to reason about and can be abused to create very unreadable code.
-
After lexing: This option is taken by cpp (at least conceptually by using the same lexer as C, even when implemented by a separate tool). If avoids some of the ugly corner cases of the first option (like being able to make the beginning or end of a comment conditional) while still being very flexible. An implementation for Scala would probably be limited to the default tokenizer state (i.e. no conditional compilation within XML expressions or string interpolation). Tokenization rules do not change very often or very much so that cross-compiling to multiple Scala versions should be easy.
-
After parsing: This is the approach taken by Rust. It limits what can be made conditional (e.g. only single methods but not groups of multiple methods with a single directive) and requires valid syntax in all conditional parts. It cannot be used for version-dependent compilation that requires new syntax not supported by the older versions. An additional concern for Scala is the syntax. Using annotations like in Rust is possible but it would break existing Scala conventions that annotations must not change the interpretation of source code. It is also much harder to justify now (rather than from the beginning when designing a new language) because old tools would misinterpret source code that uses this new feature.
-
After typechecking: This is too limiting in practice and can already be implemented (either using macros or with Scala's optimizer and compile-time constants, just like in Java).
From my experience of cross-compiling Scala code and using conditional source directories, I think that option 3 is sufficiently powerful for most use cases. However, if we have to add a new syntax for it anyway (instead of using annotations), option 2 is worth considering.
Which features do we need?
Rust's cfg
attribute + macro combination looks like a good solution for most cases. I don't expect a big demand for conditional annotations, so we can probably skip cfg_attr
. The cfg
macro can be implemented as a (compiler-intrinsic) macro in Scala, the attribute will probably require a dedicated syntax.
Sources of config options
Conditions for conditional compilation can be very complex. There are two options where this complexity can be expressed:
- Keep the predicates in the Scala sources simple (e.g. only key==value checks), requiring the additional logic to be put into the build definition.
- Or keep the build definition simple and allow more complexity in the predicates.
I prefer the first option. We already have a standard build tool which allows arbitrary Scala code to be run as part of the build definition. Other build tools have developed scripting support, too. The standalone scalac
tool would not have to support anything more than allow configuration options to be set from the command line. We should consider some predefined options but even in seemingly simple cases (like the version number) this could quickly lead to a demand for a more complex predicate language.