component-model icon indicating copy to clipboard operation
component-model copied to clipboard

Default values

Open badeend opened this issue 2 years ago • 6 comments

TLDR, these proposed changes:

  • Allow function parameters, record fields and variant case payloads to have default values, as long as they're constant.
  • When subtyping; allow variant cases to fall back on cases which have non-unit payload.
  • Strip the option type from its special status in various places.

Definition

literal-bool ::= 'false' | 'true'
literal-char ::= '"' <core:stringelem> '"'
constant ::= unit
	| (bool <literal-bool>)
	| (s8 <core:s8>) | (u8 <core:u8>)
	| (s16 <core:s16>) | (u16 <core:u16>)
	| (s32 <core:s32>) | (u32 <core:u32>)
	| (s64 <core:s64>) | (u64 <core:u64>)
	| (float32 <core:f32>) | (float64 <core:f64>)
	| (char <literal-char>) | (string <core:string>)
	| (record (field <name> <constant>)*)
	| (variant (case <name> <constant>))
	| (list <intertype> <constant>*)

Regarding the list constant: every provided <constant> must be a subtype of the list's <intertype>.

Aliases

                 false ↦ (bool false)
                  true ↦ (bool true)
   (tuple <constant>*) ↦ (record (field "𝒊" <constant>)*) for 𝒊=0,1,...
       (flags <name>*) ↦ (record (field <name> (bool true))*)
                  none ↦ (variant (case "none" unit))
     (some <constant>) ↦ (variant (case "some" <constant>))
       (ok <constant>) ↦ (variant (case "ok" <constant>))
    (error <constant>) ↦ (variant (case "error" <constant>))

Types

Every constant expression has a fully deterministic interface type.

  • Primitive constants map 1:1 to an interface type.
  • List constants declare the element intertype in the constant expression itself.
  • Record and Variant constants generate an ad-hoc interface type derived from the constant itself. Example:
Constant:
	(record
		(field "a" (s16 42))
		(field "b" (string "Wasm"))
		(field "c" (variant (case "ok" (bool true))))
		(field "d" (list u8 (u8 3) (u8 5) (u8 8) (u8 13)))
	)

Generated type:
	(record
		(field "a" s16)
		(field "b" string)
		(field "c" (variant (case "ok" bool)))
		(field "d" (list u8))
	)

Updates to existing definitions

- (record (field <name> <intertype>)*)
+ (record (field <name> <intertype> (default <constant>)?)*)
	* <constant> must be a subtype of <intertype>


- (variant (case <name> <intertype> (refines <name>)?)*)
+ (variant (case <name> <intertype> (default <constant>)? (refines <name> <constant>?)?)*)
	* The <constant> of the `default` clause must be a subtype of <intertype> from the same case.
	* The <constant> of the `refines` clause must be a subtype of <intertype> from the case <name> refers to.
	* The <constant> in the `refines` clause is optional when the case `<name>` refers to has a `default` value.


- (func <id>? (param <name>? <intertype>)* (result <intertype>))
+ (func <id>? (param <name>? <intertype> (default <constant>)?)* (result <intertype>))
	* A param's <constant> must be a subtype of <intertype>


- (flags <name>*) ↦ (record (field <name> bool)*)
+ (flags <name>*) ↦ (record (field <name> bool (default (bool false)))*)


- (option <intertype>) ↦ (variant (case "none") (case "some" <intertype>))
+ (option <intertype>) ↦ (variant (case "none" unit (default unit)) (case "some" <intertype>))


- `record`: fields can be reordered; covariant field payload subtyping; superfluous fields can be ignored in the subtype; `option` fields can be ignored in the supertype
+ `record`: fields can be reordered; covariant field payload subtyping; superfluous fields can be ignored in the subtype; missing fields are set to the `default` value declared in the supertype.


- `func`: parameter names must match in order; contravariant parameter subtyping; superfluous parameters can be ignored in the subtype; `option` parameters can be ignored in the supertype; covariant result subtyping
+ `func`: parameter names must match in order; contravariant parameter subtyping; superfluous parameters can be ignored in the subtype; missing parameters are set to the `default` value declared in the supertype; covariant result subtyping

This is already more detailed than I intended it to be. Let me know if there is interest in pursuing this any further.

badeend avatar Apr 02 '22 00:04 badeend

I've also wondered about having default values too, so this is a great issue to raise.

One reason for the current design with option is that option conveys an extra bit of semantic information from the caller to the callee (distinguishing the cases of a caller not supplying a value vs. explicitly supplying what happens to be the default value). In most situations, this doesn't matter, but it seems like in some scenarios it's useful:

  • If the desired default value is large or complicated, then it wouldn't be expressible in the constant expression language (or, if we extended the constant expression language, it might have undesirable construction overhead).
  • My impression is that option gives the per-language bindings more flexibility for picking the language-idiomatic to deal with missing values. There's a lot of variability here when you dig into the fine details (e.g., witness the subtle distinctions between null vs. undefined vs. when it comes to JavaScript in Web IDL). With option, the interface says "however your language expresses a missing parameter, do that, then communicate this to me as the none case.
  • Default values raise some subtle questions/hazards with respect to version upgrade: when I update a component that exports a function with a default value in its interface, should the default value get updated with the callee? In general, the answer should be (and is, in your sketch above) "yes". However, in a more static compilation model like C++, default values are compiled into the callee. In a component context, static bindings generation may end up compiling the default value into AOT-generated bindings baked into the caller (especially when the default value is meant to be an idiomatic language-integrate "none" value, as discussed in the previous bullet), leading to some subtle breakage down the line. With option, it's quite clear that the behavior of the none case is controlled by the callee.
  • Although maybe I'm missing part of the picture, it seems overall simpler to not have a constant-value expression language; the subtyping algorithm seems like it would be roughly similar.

So all that made me lean toward the current option design; but maybe there are other reasons to prefer default values?

One obvious one is that, when the none case implies a default value (which is not always, but perhaps most of the time), it's more explicit to include that default value in the programmatic interface, rather than relying on documentation. At some point, I think we'll want to standardize a "documentation" custom section with structured contents (rather than free-form strings) and, in that context, a structured "default value" annotation could make sense and be programmatically available to, e.g., intellisense.

lukewagner avatar Apr 04 '22 15:04 lukewagner

When posed as an either/or question, I'm ambivalent. I don't think default values are inherently better than option types or vice versa. They can both fill each other's role to some extent.

That said, I think the two concepts are orthogonal. Especially in languages that support both. For instance, these three are not the same:

void sendMail(bool asHtml = true);
void sendMail(Option<bool> asHtml);
void sendMail(Option<bool> asHtml = None);

Having only option types at their disposal forces programmers to introduce an extra state in the system where this might not be appropriate. In the preceding example, asHtml ended up with a third limbo state "None", besides true and false.

Another example is the flags interface type. Either it must be special cased in the subtyping rules or you can't add new flags in a backwards compatible way.

badeend avatar Apr 24 '22 20:04 badeend

Yeah, I suppose I see your point; sometimes you don't want to add a new None case, but you also don't want folks to have to fill in every flag/field/parameter manually.

lukewagner avatar Apr 25 '22 21:04 lukewagner

With option, the interface says "however your language expresses a missing parameter, do that, then communicate this to me as the none case.

I think languages like Java, C#, etc. would most likely use Option for their implicit nullability of all reference types. Leaving them no other way to communicate missing parameters. (Other than a nested Option<Option<<...>> ?)

Default values raise some subtle questions/hazards with respect to version upgrade (...)

Could this be solved by requiring that, in case both the subtype and supertype declare a default value for a particular field/parameter, the values must be compatible (according to the subtyping rules) ? That way it wouldn't matter whether the caller or the callee is responsible for default values; the end result is effectively the same.

Though, with that restriction in place, I think it makes sense to make the caller responsible for providing the default values. See examples below.

One reason for the current design with option is that option conveys an extra bit of semantic information from the caller to the callee (distinguishing the cases of a caller not supplying a value vs. explicitly supplying what happens to be the default value)

If that distinction is truly needed, you can still use an Option type. No harm done.

At some point, I think we'll want to standardize a "documentation" custom section with structured contents (rather than free-form strings) and, in that context, a structured "default value" annotation could make sense and be programmatically available to, e.g., intellisense.

I don't think the documentation/annotation/metadata direction is enough whenever the subtype contains a field/parameter with a default value the supertype is not aware of. See example 2 below. In this case the component model glue code has to be aware of default values.

(...) it seems overall simpler to not have a constant-value expression language; the subtyping algorithm seems like it would be roughly similar.

I completely understand. I'll leave that tradeoff for you to make.


Examples

It would be awesome if, through some mechanism, the following examples would Just Work™.



/** EXAMPLE 1: **/

// Component A:
export sendEmail() { ... }

// Component B:
import sendEmail()

sendEmail(); // No transformation needs to be done.



/** EXAMPLE 2: **/

// Component A:
export sendEmail(asHtml: bool = false) { ... }

// Component B:
import sendEmail()

sendEmail(); // Component model glue code transforms this call to `sendEmail(false)`



/** EXAMPLE 3: **/

// Component A:
export sendEmail(asHtml: bool) { ... }

// Component B:
import sendEmail(asHtml: bool = false)

sendEmail(); // The language of component B transforms this call to `sendEmail(false)`
sendEmail(false);  // No transformation needs to be done. 



/** EXAMPLE 4: **/

// Component A:
export sendEmail(asHtml: bool = false) { ... }

// Component B:
import sendEmail(asHtml: bool)

sendEmail(false); // No transformation needs to be done. 



/** EXAMPLE 5: **/

// Component A:
export sendEmail(asHtml: bool = false) { ... }

// Component B:
import sendEmail(asHtml: bool = false)

sendEmail(); // The language of component B transforms this call to `sendEmail(false)`
sendEmail(false); // No transformation needs to be done.



/** COUNTER EXAMPLE: Should NOT work? **/

// Component A:
export sendEmail(asHtml: bool = false) { ... }

// Component B:
import sendEmail(asHtml: bool = true) // Import triggers an error in the component model subtyping rules?

// sendEmail();

badeend avatar Jun 06 '22 16:06 badeend

I think languages like Java, C#, etc. would most likely use Option for their implicit nullability of all reference types. Leaving them no other way to communicate missing parameters. (Other than a nested Option<Option<<...>> ?)

My expectation here is that the language bindings start with the interface type (which does not have implicit nullability, only option<T>) and then coerces to and from that. So if the bindings accept a source-language reference for a non-nullable interface-type T, then on source-language null, the bindings would either trap, throw or coerce in some language-bindings-defined manner. Thus, option<T> would not be forced to inherit the default-nullability of languages.

Could this be solved by requiring that, in case both the subtype and supertype declare a default value for a particular field/parameter, the values must be compatible (according to the subtyping rules) ?

That's an interesting idea! Yes, I suppose theoretically it could. It seems like this would put further requirements on this constant-valued interface-typed expression language, though.

Thanks for writing out all those examples! I hadn't even thought about the interaction between default values and function subtyping allowing extra optional parameters... some of those cases were rather surprising to me and it seems like we'll need to think long and hard about this.

lukewagner avatar Jun 07 '22 00:06 lukewagner

Nice to see this fleshed out proposal. Having to deal with None v Some branches in all places where things are optional does create overhead that is nicer to avoid with default values.

Is the intention for the "default" default implementations of types to be defined? Can we extend the proposal semantics to the WIT definitions at this point?

guybedford avatar Dec 16 '22 18:12 guybedford