Raku / problem-solving

🦋 Problem Solving, a repo for handling problems that require review, deliberation and possibly debate
Artistic License 2.0
70 stars 16 forks source link

Behavior of type-constrained parameters is surprising given other behavior #426

Open landyacht opened 6 months ago

landyacht commented 6 months ago

Minimal Example

sub foo(Int @a) { dd @a; }
foo [1, 2, 3];
# Type check failed in binding to parameter '@a'; expected Positional[Int] but got Array ([1, 2, 3])

Why This is Surprising

Other behavior in Raku would lead one to believe that type constraints apply not to the container (variable) as a whole, but rather to the element(s) that can be put into our pulled out of the container, the exact meaning of which depends on the type of said container.

Specifically, we do not constrain an array to only hold Ints with Array[Int] @a, rather we do Int @a. We do not constrain a hash to hold only Ints with Hash[Int] %h, rather we do Int %h. We do not constrain a callable to return only Ints with Callable[Int] &c, rather we do Int &c. All this implies to the newly-learning Raku developer that constraints apply "intelligently" based on container shape, rather than "dumbly" to the container as a whole. In other words, the user doesn't have to worry about typing whole containers as long as the contained elements satisfy the constraint.

This is further reinforced by the fact that literals with no explicit type specified can be assigned into type-constrained containers, e.g. my Int @ints = [1, 2, 3]. Nowhere does the programmer explicitly say that [1, 2, 3] is Array[Int], yet the language accepts it into an Int-constrained @-sigiled container. Why, then, should the Minimal Example fail?

Should This Behavior Change?

More experienced developers understand that the underlying difference is that between assignment (=) and binding (:=), and what happens when calling a code block is binding to parameters, not assigning. While that suffices as a technical explanation, it does not feel sufficient as a philosophical justification for the surprising behavior. I (and I believe others too) would like to be able to apply the "intelligent constraint" principle consistently, or at least in the absence of something "unusual" like my @a is Array[Foo].

Furthermore, it seems to violate the concept of optional, gradual typing. Going back and adding type constraints to your quickly-prototyped code should not break it if that constraint was always satisfied anyway. It feels wrong that the Minimal Example could be made to work, with equivalent function, by removing the type constraint on foo's parameter.

Practical Considerations

Making parameter binding behave as naïvely expected is easy enough for cases where the container's values are all known at compile time. However, in the general case, we would likely have to scan through arrays and hashes, and I'm not even sure what sort of black magic would be necessary for callables (perhaps those are an acceptable exception where the user must explicitly call out the return type).

librasteve commented 6 months ago

WhT would that do for List? Break? I mean (1, 2, 3) as Int

yep, I think that going as on a thing that can't be typed should break

BTW I like all the options [] as Int, [] of Int and []:Int - if forced I would say that of is probably the most natural and second @raiph' observation of the fit with the current trait

raiph commented 6 months ago

@librasteve

yep, I think that going as on a thing that can't be typed should break

If we're talking about as (at least as I thought @vrurg had conceived it) then it's the RHS type that would ultimately determine whether the coercion fails, not the LHS. (And any such failure will end compilation, unless the LHS and/or RHS are allowed to be dynamic and someone takes advantage of that and the combination fails to coerce due to a fundamental mismatch of container types, so no coercion is even available, or there's a failure to convert some element.)

In contrast, if we're talking about of rather than as then it could indeed break due to the LHS having a non-parameterizable of type, with List being a classic case. Fortunately it would again be a compile phase catch (unless dynamism is allowed as @vrurg discussed and I just recapped above, but in that regard I think as and of are no different).

raiph commented 6 months ago

What about a variant for as (or of) something like:

[1,2,3] as *

where the * invokes .are to choose the type rather than coercing to a fixed type?

jubilatious1 commented 6 months ago

@vrurg wrote:

Whenever I need to pass in a typed array what I usually do is:

foo( my Int @ = [1,2,3] );

Not working here (tried in a script and in the REPL). But I'm only on Rakudo 2023.05, so is this functionality due to a recent commit? And how to call?

~ % raku
Welcome to Rakudo™ v2023.05.
Implementing the Raku® Programming Language v6.d.
Built on MoarVM version 2023.05.

To exit type 'exit' or '^D'
[0] > foo( my Int @ = [1,2,3] );
===SORRY!=== Error while compiling:
Undeclared routine:
    foo used at line 1

[0] >

@alabamenhu

FCO commented 6 months ago

Undeclared routine It seems you haven't declared the foo function

https://glot.io/snippets/gw2kfa8j95

jubilatious1 commented 6 months ago

Thanks @FCO !

[1] > sub foo(Int @a) { dd @a }
&foo
[2] > foo( my Int @ = [1,2,3] ){};
Array[Int @ = Array[Int].new(1, 2, 3)
Nil

Using the sub name typify instead of foo:

[7] > sub typify(Int @a) { @a }
&typify
[8] > typify my Int @x = [1,2,3];
[1 2 3]
[9] > dd typify my Int @x = [1,2,3];
Array[Int @x = Array[Int].new(1, 2, 3)
Nil
[10] > dd typify my Int @x = [1,2,"three"];
Type check failed for an element of @x; expected Int but got Str ("three")
  in block <unit> at <unknown file> line 1

[10] >

Maybe a sub named something like typify (lowercase) errors on Type-checking violations, while Typify (uppercase) coerces?

ab5tract commented 6 months ago

@jubilatious1 wrote:

Maybe a sub named something like typify (lowercase) error on Type-checking, while Typify (uppercase) coerces?

While I do like the idea of making any coercion opt in to be as simple as possible, I think we have better mechanisms for differentiating between these forms than capitalization.

Even a whole new keyword would be preferable, IMO. Though naming it successfully would require quite a discussion in itself :)

That said, it really feels like the more natural location for specifying this on the callee -side is as a trait on the parameter.

From the perspective of specifying at the call site, so far the 'as' proposal has my vote.

niner commented 6 months ago

[1, 2, 3] of Int makes most sense to me if we want a short version (i.e. one that doesn't explicitly mention Array) as it reas like "this array of ints" and the of trait is also used for specifying the member type and Array.of does return that member type constraint. It's a quite natural fit.

I'd veto [1, 2, 3] as Int but can see [1,2, 3] as Array[Int] working.

Anyway this can easily be prototyped in module space (as has been demonstrated).

niner commented 6 months ago

What about a variant for as (or of) something like:

[1,2,3] as *

where the * invokes .are to choose the type rather than coercing to a fixed type?

That sounds to me like "let's introduce type constraints to the code as an additional safety net and then...let's ignore those type constraints and have the compiler guess what we mean".

vrurg commented 6 months ago

let's ignore those type constraints and have the compiler guess what we mean".

Except for one thing: we explicitly ask the compiler to do it for us. I see more significant problem here. While the example above is all transparent due to use of constant values, something like @a as * might be way more surprising and even WAT-ish in production.

alabamenhu commented 6 months ago

[1, 2, 3] of Int makes most sense to me if we want a short version (i.e. one that doesn't explicitly mention Array) as it reas like "this array of ints" and the of trait is also used for specifying the member type and Array.of does return that member type constraint. It's a quite natural fit.

I'd veto [1, 2, 3] as Int but can see [1,2, 3] as Array[Int] working.

Anyway this can easily be prototyped in module space (as has been demonstrated).

Agree that as Int looks wrong. While I do like the simplicity and congruity of, the issue is that it will probably assume Array, yeah? The calling sub might insist on some other Positional (not easily indicated with @ sigils at the moment, see https://github.com/rakudo/rakudo/issues/5540 ) and we'd have the same issue. Unless maybe we could somehow figure out [1,2,3] as Array of Ints but there we might be treading into AppleScript territory lol.

Ultimately, I'm not opposed to of, I just think its utility would be a bit more limited, relative to as which could be a general purpose compile-time-able COERCE mechanism.

I'll sign up to try to work on these in module space and see what we think about them. Will try to get something this weekend maybe.

FCO commented 6 months ago

that it will probably assume Array, yeah?

It could just use the same Positional as passed?

raiph commented 6 months ago

@alabamenhu

While I do like the simplicity and congruity of, the issue is that it will probably assume Array, yeah?

You meant an issue -- there are multiple issues. ;)

I wasn't thinking it would assume an Array. I was thinking it would construct a new container that's the same container type as the LHS. So something like:

"{LHS.^name ~~ / .* \[? /}[{RHS.^name}]".EVAL.(LHS)

The calling sub might insist on some other Positional

To be clear, I was never suggesting of replaces as, if that's what you were thinking.

Anyhow, while I get that the of and is traits don't currently work on parameters, I failed to figure out what you meant was a problem after looking at the issue you linked.

Ultimately, I'm not opposed to of, I just think its utility would be a bit more limited, relative to as which could be a general purpose compile-time-able COERCE mechanism.

FWIW I thought of of as also a compile-time-able COERCE mechanism, just one that presumed it should produce the same container as the LHS. I was imagining it was plausible that a large chunk of uses in real world code, perhaps most, would be that case.

But like I said, there are multiple issues, with the most basic being whether having an extra construct is worthwhile. I'm now thinking of isn't worthwhile.

gfldex commented 6 months ago

More generally allowing this for any type, would be slightly more complicated, but still doable.

multi circumfix:<[ ]>(*@a, *%_){
    my $type = ::(%_.head.key);

    die ('Exception to be done.') if $type.WHAT === Failure && $type.exception ~~ X::NoSuchSymbol;
    Array[$type].new: @a
};
dd [1,2,3]:Foo

It sure can! But we may exchange one confusing error with another when this syntax is chained with other operators, because the adverb goes to the wrong operator. Also, there will be a runtime error if the type is not found (and it is kinda slow).

It's getting really weird when we change that with constant:

constant Int @a = [1,2,3]:Int;
dd @a;
# OUTPUT: Missing initializer on constant declaration
#         at /home/dex/projects/raku/tmp/2021-03-08.raku:2898
#         ------>     constant Int⏏ @a = [1,2,3]:Int;

Looke like another Rakudobug.

gfldex commented 6 months ago

The following syntax would allow runtime lookups (and looks neat in my eyes).

my Int @array = Int [1,2,3];
my @list = Int (1,2,3);
my $type = 'Str';
my Str @a = ::($type) <a b c>;
sub foo(Int @a) { }
foo(Int (1,2,3));