-
Notifications
You must be signed in to change notification settings - Fork 1
Decorators macros
Abra already has decorators to some extent used throughout the prelude and standard library, to denote bindings to external functions (@CBinding
), functions which are implemented natively within the compiler (@Intrinsic
), as well as functions which do not yet have an implementation (@Stub
, although that one will dwindle in usage once I fully implement all standard library methods).
It would be useful to have some kind of way of defining decorators in user-space, as well as providing some manner of type-safety around them. For example, the @CBinding
decorator above could be declared like
decorator CBinding {
name: String
}
and would need to be imported like any other named declaration before it can be used:
import CBinding from "./wherever"
@CBinding(name: "fopen")
export func fopen(...)
Much like types, these can be constructed using a constructor/initializer style and named arguments can be passed. However, due to the complexities involved, it may be necessary to ensure that these arguments can only be static constant values. This is especially necessary for the @CBinding
decorator, which requires static introspection at compile-time. For example, the following code should fail to typecheck
import CBinding from "./wherever"
val fopen = "fopen"
@CBinding(name: fopen)
// ^^^^^
// Error: non-constant expression used as decorator argument
export func fopen(...)
This would mean that the only valid expressions for decorator arguments are:
- string literals (
"hello"
) - numeric literals (
123
,-123
,0.0123
) - boolean literals (
true
,false
) - tuple literals (
(0, true)
,(-123, ("a", true))
) - arrays of any valid such expressions (
["a", "b"]
,[[1, 2], [3]]
,[(0, true), (1, false)]
)
There's nothing preventing someone from using the @CBinding
decorator on a type, for example:
import CBinding from "./whatever"
@CBinding(name: "FILE")
export type File { ... }
Ideally, this should result in a typechecker error and there should be some way to indicate when defining a decorator the ways in which it is intended to be used. One way to do this would be to leverage the concept of traits and have special-purpose traits pre-defined which represent the various valid locations that decorators could belong.
trait FunctionDecorator {}
trait TypeDecorator {}
trait FieldDecorator {}
trait EnumDecorator {}
trait EnumVariantDecorator {}
...
Then, when defining the @CBinding
decorator, it can conform to the FunctionDecorator
trait:
decorator CBinding : FunctionDecorator {
name: String
}
These traits are special traits that would be recognized by the compiler, and allow the user to have more fine-grained control over how decorators are meant to be used. For example, the following code would now properly fail to typecheck:
import CBinding from "./whatever"
@CBinding(name: "FILE")
export type File { ... }
// ^^^^
// Error, decorator @CBinding cannot be applied to a type
Another potential application of decorators is to allow for compile-time code transformation/generation. A good example of this would be serializing/deserializing to/from json. Consider the following example:
json
module
export enum JsonValue {
Null
Boolean(value: Bool)
String(value: String)
Number(value: Float)
Array(items: JsonValue[])
Object(items: Map<String, JsonValue>)
}
export trait JsonSerialize {
func toJson(self): JsonValue
}
export trait JsonDeserialize {
func fromJson(json: JsonValue): Self
}
import JsonSerialize, JsonDeserialize, JsonValue from "json"
type Person : JsonSerialize, JsonDeserialize {
firstName: String
lastName: String
trait func toJson(self): JsonValue = JsonValue.Object(...)
trait func fromJson(json: JsonValue): Self = Person(...)
}
There's a lot of boilerplate that needs to be written in order to facilitate json serialization/deserialization, most of which can be fairly easily determined at compile-time based on the structure of the type. Imagine such a decorator existed, what would it need to do?
// `json` module
decorator Jsonify : TypeDecorator {
???
}
// user-defined module
import Jsonify from "json"
@Jsonify
type Person {
firstName: String
lastName: String
}
There are several things that this macro would need to do:
- Add conformances of
Person
to theJsonSerialize
andJsonDeserialize
traits - Generate the appropriate
toJson
andfromJson
methods to satisfy those traits - Verify that the fields of the type also satisfy those traits, or else surface a typechecker error
I'm imagining a mechanism for the decorator-specific traits to have potentially-overrideable methods in order to allow for "macro"-like behavior, but I want to do it in as type-safe a way as I can. There are a few options.
There could be a method on the TypeDecorator
trait which would allow for augmentations to the type.
decorator Jsonify : TypeDecorator {
trait func augment(self, meta: TypeMetadata): Result<String, DecoratorError> {
val lines: String[] = []
for field in meta.fields {
if !field.typeMeta.conformsTo("JsonSerialize") return Err(DecoratorError(...))
lines.push("items[\"${field.name}\"] = self.${field.name}.toJson()")
}
"extend ${meta.typeName} : JsonSerialize {" +
" trait func toJson(self): JsonValue {"+
" val items: Map<String, JsonValue> = {}" +
lines.join("\n") +
" return Json.Object(items)" +
" }" +
"}"
}
}
The main idea here is that, rather than providing a String which basically gets injected into the source file, there could be a well-defined list of "augmentation operations" that decorator methods could return. Then, the engine that is executing the macros can apply those augmentations in a well-defined way. For example:
decorator Json : TypeDecorator {
func augmentTypeDeclaration(self, typeMeta: TypeMetadata): Result<Augmentation[], DecoratorError> {
val augmentations: Augmentation[] = []
val jsonModuleImportName = "json_${randomString()}"
agumentations.push(Augmentation.ModuleAugmentation(ModuleAugmentation.AddImport(module: "json", alias: jsonModuleImportName)))
augmentations.push(Augmentation.TypeAugmentation(TypeAugmentation.AddTraitConformance("$jsonModuleImportName.JsonSerialize")))
augmentations.push(Augmentation.TypeAugmentation(TypeAugmentation.AddTraitConformance("$jsonModuleImportName.JsonDeserialize")))
Ok(augmentations)
}
func augmentTypeDefinition(self, typeMeta: TypeMetadata): Result<Augmentation[], DecoratorError> {
val augmentations: Augmentation[] = []
val jsonModuleImportName = "json_${randomString()}"
agumentations.push(Augmentation.ModuleAugmentation(ModuleAugmentation.AddImport(module: "json", alias: jsonModuleImportName)))
val toJsonMethodBuilder = FunctionBuilder(name: "toJson", isInstanceMethod: true, returnType: "$jsonModuleImportName.JsonValue")
toJsonMethodBuilder.addCode("val items: Map<String, $jsonModuleImportName.JsonValue> = {}")
for field in typeMeta.fields {
if !field.typeMetadata.conformsToTrait("$jsonModuleImportName.JsonSerialize") {
return Err(DecoratorError(
location: field.location,
message: "Field ${field.name} has type ${field.typeMetadata.repr()}, which does not conform to the JsonSerialize trait"
))
}
val name = if field.getDecorator<JsonName>("JsonName") |dec| dec.name else field.name
toJsonMethodBuilder.addCode("items[$name] = self.$name.toJson()")
}
toJsonMethodBuilder.addCode("return $jsonModuleImportName.JsonValue.Object(items)")
augmentations.push(Augmentation.TypeAugmentation(TypeAugmentation.AddInstanceMethod(toJsonMethodBuilder.build())))
val fromJsonMethodBuilder = FunctionBuilder(name: "fromJson", isInstanceMethod: false, returnType: "Self")
fromJsonMethodBuilder.addParameter(name: "json", typeDesc: "$jsonModuleImportName.JsonValue")
// ...
augmentations.push(Augmentation.TypeAugmentation(TypeAugmentation.AddStaticMethod(fromJsonMethodBuilder.build())))
Ok(augmentations)
}
}
There's a lot more room to maneuver in this space, but also it's a lot more difficult to get it right.