~sircmpwn/hare#88: 
Allow type and constant declarations in function bodies

Hare v2 has a better scope management system that should be able to handle this pretty easily. Needs spec update.

Status
REPORTED
Submitter
~sircmpwn
Assigned to
No-one
Submitted
4 years ago
Updated
11 months ago
Labels
harec spec

~sebsite 1 year, 6 months ago

Unless we fundamentally rethink Hare's type system, allowing type declarations in function bodies isn't possible:

export fn main() void = {
    type t = int;
    let x = {
        type t = (int, int);
        yield (0, 0): t;
    };
    // x's type is still "t", but "t" refers to something different
    let x = if (true) x else void;
    x as t; // ???

    {
        type t = int; // identical to shadowed declaration
        // is this a distinct type from the other "t"?
        // what's its type hash?
        // how do you even determine the hash of an alias
        // without any additional context?
    };
};

~sircmpwn 1 year, 6 months ago

On Thu Sep 14, 2023 at 6:16 AM CEST, ~sebsite wrote:

Unless we fundamentally rethink Hare's type system, allowing type declarations in function bodies isn't possible:

export fn main() void = {
    type t = int;
    let x = {
        type t = (int, int);
        yield (0, 0): t;
    };
    // x's type is still "t", but "t" refers to something different
    let x = if (true) x else void;
    x as t; // ???

fails because t here refers to the int type

{
    type t = int; // identical to shadowed declaration
    // is this a distinct type from the other "t"?
    // what's its type hash?
    // how do you even determine the hash of an alias
    // without any additional context?
};

};

We can make local types use \0.whatever to compute their hash, perhaps. We can also disallow shadowing types.

~sebsite 1 year, 6 months ago

fails because t here refers to the int type

Right, but currently both t's have the same type hash, so we'd need to find a way to make their hashes different. And having the hash depend on the context it's declared in is pretty weird, and I feel like it's probably a bad idea.

We can make local types use \0.whatever to compute their hash, perhaps.

I assume \0 is just an integer which increments each time an alias's hash is computed? Or is it the number of active nested scopes? The latter would mean that different types within different functions could have the same hash, which is weird but maybe fine? The spec would definitely need to be updated though. I'm still not a fan, but I'm not a fan of the former interpretation either, since it would break programs like haretype with aliases. Or, more generally, the hash of an alias would be unpredictable, and could change just by reorganizing code within a subunit.

We can also disallow shadowing types.

This wouldn't solve the problem, since we'd still need to ensure different aliases have different hashes:

export fn main() void = {
    let x = {
        type t = int;
        yield 0: (t | void);
    };
    type t = str; // no shadowing
    x as t; // but this still needs to fail
};

I'm generally just not a fan of allowing in-scope bindings to have a type which is out of scope, and thus can't be referenced. Similar to why we require that the types of exported declarations also be exported (it simplifies the implementation ofc, but even when not taking implementation complexity into account it's still the right thing to do).

Additionally, assuming that we implement #871, it's still possible to create a binding of a type which is no longer in-scope. even though the type itself is no longer representable:

export fn main() void = {
    let x = {
        type t = int;
        yield 0: t;
    };
    // x's type is out-of-scope, but we can still create a binding with the type:
    type t = int; // new alias, guaranteed to be unique
    let y = match (if (true) x else 0: t) {
    case t => abort();
    case let y =>
        yield y; // new binding created, with unrepresentable type
    };
    // x and y have the same type!
    assert((if (true) x else y) == 0);
};

One way to eliminate the problem would be to require that objects with type t (or with a type that uses t) are only accessible within t's scope (in addition to disallowing shadowing). So, yielding a value with a locally-defined type would be disallowed. This still allows for the most-common (I think) use case of defining a function-local type for internal use. If we're gonna allow local types (which I'm still skeptical about), I think this is probably the best approach(?)

~turminal 1 year, 6 months ago

IMO your example should error out already at

{
    type t = (int, int);
    yield (0, 0): t;
};

Because that's the local equivalent of exporting a value whose type is not exported.

More generally, I think this problem points of a bigger and more general issue that we need to discuss - are Hare type aliases truly standalone types or are they just syntactic shortcuts for whatever their underlying type is? Right now, they're somewhere in between and that's causing some problems in various places.

~ecs 1 year, 6 months ago

yeah we definitely shouldn't allow types to escape the scope they're defined within. no opinions on the rest of this as of now

~sircmpwn 1 year, 6 months ago

having the hash depend on the context it's declared in is pretty weird, and I feel like it's probably a bad idea.

Hashes already depend on the context it's declared in: the relevant module scope.

The hash could be hash(namespace + function name + serial number) where serial number is assigned from 0 and increments for each type defined in a given function. I don't think the spec needs to be updated for this (at least not beyond allowing types to be defined in functions) because the means of establishing the type hash is implementation defined.

I'm generally just not a fan of allowing in-scope bindings to have a type which is out of scope, and thus can't be referenced. Similar to why we require that the types of exported declarations also be exported (it simplifies the implementation ofc, but even when not taking implementation complexity into account it's still the right thing to do).

We should indeed prevent types from escaping their scope.

~sebsite 1 year, 6 months ago

Hashes already depend on the context it's declared in: the relevant module scope.

Sure, but the difference is the behavior there is predictable and consistent.

I don't think the spec needs to be updated for this (at least not beyond allowing types to be defined in functions) because the means of establishing the type hash is implementation defined.

My concern was moreso that the spec should allow types within different scopes to have identical hashes, even if the types are different, since in non-cursed code this would never be an issue. Your suggestion (namespace + function name + serial number) still allows this in some instances:

@test fn f() void = {
    type t = str;
    let x = "": (t | void);
    f(&x);
};

fn f(x: *opaque) void = {
    type t = int;
    let x = x: *(t | void);
    *x as t; // passes, but the type is wrong
};

This is a very contrived example, and I think it's reasonable that fucked-up pointer casts yield fucked-up results. We could make the hashing algorithm generate unique types even here, but I'm not sure that's worth it, and either way I think it makes sense to permit identical type hashes between types in different functions anyway.

We should indeed prevent types from escaping their scope.

After more thought, +1 to allowing local types given this limitation. I'm pretty sure(?) shadowing wouldn't cause any issues here either.

~sebsite 1 year, 6 months ago

More generally, I think this problem points of a bigger and more general issue that we need to discuss - are Hare type aliases truly standalone types or are they just syntactic shortcuts for whatever their underlying type is? Right now, they're somewhere in between and that's causing some problems in various places.

How are they in between? My impression is that type aliases are standalone types in nearly every way. The two exceptions I can think of are enum aliases (but those are a whole separate issue, see #838), and import aliases, which actually are pretty much just syntactic shortcuts (come to think of it, we should make sure the spec is clear about this).

~turminal 1 year, 6 months ago

They are treated as standalone in harec (declaration scanning relies heavily on that for example) but that's mostly an implementation detail, when it comes to actual language mechanics, we compute all sorts of type compatibility (type_is_assignable, type_is_castable, ...) by first dealiasing everything and comparing what's underneath - that's basically the definition of a structural type system. Maybe this is not directly relevant here, my thinking was that resolving related problems elsewhere will make it easier to decide what's the right thing to do here.

~sebsite 1 year, 6 months ago

I sorta see the opposite as true: within Hare (the language), aliases are standalone types, but things like assignment and casting handle aliases by converting them to their underlying type. The implementation detail is that harec uses type_dealias to dealias all nested aliases at once. Everything else is documented in the spec.

So, put another way, aliases are standalone types that (on their own) have the same castability and assignability semantics as their underlying type.

~ecs REPORTED IMPLEMENTED 1 year, 5 months ago

Sebastian referenced this ticket in commit fc81784.

~ecs 1 year, 5 months ago

Sebastian referenced this ticket in commit 7d15daf.

~sebsite IMPLEMENTED REPORTED 1 year, 4 months ago

Register here or Log in to comment, or comment via email.