alshdavid / BorrowScript

TypeScript with a Borrow Checker. Multi-threaded, Tiny binaries. No GC. Easy to write.
1.45k stars 16 forks source link

Polymorphism #7

Closed asafigan closed 1 year ago

asafigan commented 3 years ago

I prefer composition to inherence and seldom use it. I would be find with excluding class inherence from the TSBC specification.

I also rarely use inheritance and would be fine without it. There needs to be some type of polymorphism. A few possible options are duck typing, interfaces, and traits.

In typescript, I mostly use duck typing using types or interfaces. In typescript, there is little difference between these two and interfaces are not what most OOP languages consider as interfaces.

There are also normal OOP interfaces which need to be declared on a class and their methods implemented. This is restrictive because libraries can't add their interfaces to standard or external types.

Traits are more flexible than interfaces but they also have a lot of weird edge cases. One example is that you have to import them before using associated methods. Another being the weird rules around implementation like which modules are allowed to implement what traits on what types.

I think buck typing is the most intuitive for most people, but I also think that it gets really complicated to implement in a statically typed language especially if you have generics like Promise<T>.

SuperSonicHub1 commented 3 years ago

duck typing

How is duck typing going to work in a strongly typed compiled language?

Seeing as Rust already has traits, it makes sense for that to be inherited in TSBC. I imagine traits would just use TS's pre-existing interface syntax. I don't think importing is going to be much of an issue; TSBC mostly lacks built-ins, so you'll already be doing a lot of importing for Math and console. I believe the flexibility and strong typing overpower any other baggage traits will come with.

alshdavid commented 3 years ago

Hey thanks for your issue. I certainly feel it's an area that needs more thought in this specification.

There are also normal OOP interfaces [...] This is restrictive

This is my personal taste but I am not a big fan of these kinds of implementations, where importing a namespace inexplicably adds methods to an object. I often find it unclear which namespace imported which methods without assistant tooling and it's particularly annoying when viewing source code that's dependant on external namespaces in a web viewer like GitHub. In order to identify which method belongs to which namespace you must install the project's dependencies and use an IDE.

Please correct me if I am wrong but I believe Rust traits operate similarly to this and, personally, it's not to my taste - but at the same time it might be better and it might be me that's the problem.

In typescript, I mostly use duck typing using types or interfaces.

When I approach polymorphism in TypeScript, I tend to have consumers accept an interface, where the implementation is determined by the caller. This is similar to Go's style, though I don't use such granular interfaces like Reader Writer & ReaderWriter.

As an example:

interface IFoo {
  foo(): void
}

class Foo {
  foo() {}
}

class FooMock {
  foo = jest.fn()
}

function bar(foo: IFoo) {
  foo.foo()
}

bar(new Foo())
bar(new FooMock())

This is further enhanced by using implements which I recommend. class Foo implements IFoo {

In Go this model is successful because the standard library provides high quality default interfaces (like the aforementioned Reader interface) which the community chooses to use - giving community packages high levels of interoperability, maintaining cohesion without introducing coupling.

In the JavaScript world there are few pervasive standards, Promise stands out but there is no standardised method for dealing with streams (though perhaps EventTarget is that now).

In typescript, [...] I think duck typing is the most intuitive for most people

I would very much like to incorporate duck typing if possible. As you are sure there are limitations from a compiler standpoint when it comes to statically compiled languages and structural types.

Though I will experiment with generating a struct through type inference which represents the structural type described in the source.

Extending classes

At this stage I don't know what that would look like. As you know JavaScript has prototypical inheritance and who know what this means half of the time. There is value to extension of course - in some libraries I have authored, I was able to reduce the overall characters of code written and there's the benefit of being able to use instanceof against the super class.

However I would need to be very clear on how I want the inheritance model to work here before proceeding with adding it. I am open to suggestion here though.

shortercode commented 3 years ago

I too favour composition over inheritance. Actually in most cases I don't use classes these days unless theres a clear benefit, preferring to specify an interface instead. As an alternative to inheritance I'd like to suggest a model I developed while working on my own language prototypes. It's inspired by several other languages, and designed to be easy to implement with good performance characteristics ( mostly static dispatch ).

// interfaces declare public methods, and can include default implementations
interface Bar {
  c (): string;
  e (): string {
    return 'e';
  }
}

// classes declare members and methods. which can be public or private. inheritance is not supported.
class Foo implements Bar {
  a: string
  c (): string {
    return 'c'
  }
  toString (): string {
    return this.a;
  }
}

interface Stringable {
  toString(): string
  toLocaleString(): string {
    return this.toString();
  }
}

// both the class and the interface are valid types
function main (a: Foo, b: Bar, c: Stringable) {
  // standard methods available
  a.c() // string
  // as are default implementations provided by "implemented" interfaces
  a.e() // string
  // Stringable isn't implemented, so while it's compatible you don't get the default method
  a.toLocaleString() // TypeError Foo.toLocaleString is not defined

  // can access methods defined in the interface
  b.c()
  // but not others from the type
  b.d() // TypeError Bar.d is not defined
  // also default implementations
  b.e()

  // as it's been wrapped as a Stringable we can access the default method
  c.toLocaleString() 
}

const data = new Foo();
main(data, data, data);

At a runtime level an object of type Foo is just a pointer to its data, all method calls are static function calls. When you pass that object as an interface it gets wrapped into a tuple [data_pointer, function_table] with a specific function table describing how the type implements the interface. This functions table is generated at compile time.

It's not a requirement that a type "implements" an interface, so long as it meets the requirements of the interface you can pass it to functions that expect that interface. However, the class itself won't get access to the default methods an interface supplies.

While these 'default' methods seem like a minor feature, they effectively provide something akin to a mixin. Allowing for a composed class with many features.

Overall it's similar to Rust, without the scoping issues that traits have, and mostly compatible with TypeScript syntax.

SuperSonicHub1 commented 3 years ago

I really like this!

On Sun, Oct 3, 2021 at 9:30 AM Iain Shorter @.***> wrote:

I too favour composition over inheritance. Actually in most cases I don't use classes these days unless theres a clear benefit, preferring to specify an interface instead. As an alternative to inheritance I'd like to suggest a model I developed while working on my own language prototypes. It's inspired by several other languages, and designed to be easy to implement with good performance characteristics ( mostly static dispatch ).

// interfaces declare public methods, and can include default implementations interface Bar { c (): string; e (): string { return 'e'; } }

// classes declare members and methods. which can be public or private. inheritance is not supported. class Foo implements Bar { a: string c (): string { return 'c' } toString (): string { return this.a; } }

interface Stringable { toString(): string toLocaleString(): string { return this.toString(); } }

// both the class and the interface are valid types function main (a: Foo, b: Bar, c: Stringable) { // standard methods available a.c() // string // as are default implementations provided by "implemented" interfaces a.e() // string // Stringable isn't implemented, so while it's compatible you don't get the default method a.toLocaleString() // TypeError Foo.toLocaleString is not defined

// can access methods defined in the interface b.c() // but not others from the type b.d() // TypeError Bar.d is not defined // also default implementations b.e()

// as it's been wrapped as a Stringable we can access the default method c.toLocaleString() }

const data = new Foo(); main(data, data, data);

At a runtime level an object of type Foo is just a pointer to its data, all method calls are static function calls. When you pass that object as an interface it gets wrapped into a tuple [data_pointer, function_table] with a specific function table describing how the type implements the interface. This functions table is generated at compile time.

It's not a requirement that a type "implements" an interface, so long as it meets the requirements of the interface you can pass it to functions that expect that interface. However, the class itself won't get access to the default methods an interface supplies.

While these 'default' methods seem like a minor feature, they effectively provide something akin to a mixin. Allowing for a composed class with many features.

Overall it's similar to Rust, without the scoping issues that traits have, and mostly compatible with TypeScript syntax.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub, or unsubscribe.

mindplay-dk commented 3 years ago

Interfaces with default implementations are another way to describe traits, right?

It's not a bad idea, although we would probably still need extends for alignment with TS? I suppose, implementation wise, the compiler can treat a class and extends as an interface with all default implementations - whereas, type-checking wise, perhaps classes could be treated like the default interfaces that some langs have?

For example, in Dart, you can write class X implements Y, where Y is a class - this has interesting ergonomic consequences, such as being able to mock anything in tests, and never having to slavishly derive an ISomething for every Something, like we generally see in the Java and C# worlds. (It's one of my favorite things about Dart.)

Imagine if most of the practical differences between classes and interfaces were removed? So the key difference would be, classes have constructors - if, in every other regard (run-time and type-checking) classes and interfaces were just "types", this could make the language really predictable and easy to learn. (And possibly easier to implement.)

shortercode commented 3 years ago

It's similar yes, some of the ideas for it came from Rusts trait system. Although it side steps some of the more unusual behaviour that traits bring; trait scoping, unsized type limits and multiple implementation blocks. Making it all easier to use I hope.

There's still room for extends, it depends how close BorrowScript keeps to TypeScript behaviourally/syntactically.

The biggest practical difference between classes and interfaces in my mind is that interfaces cannot contain data. So if you have default methods you can use the interfaces in a way similar to a class, but they are limited in that they cannot have any members.

Classes will generally have different memory layouts, so 2 classes with the member a of type string cannot use the same accessor. It's possible to use a dynamic lookup but this comes with performance and memory penalties.

chop0 commented 3 years ago

I think inheritance should be included in some form; it provides a level of flexibility that can be really useful and there are plenty of cases in which it's more natural than composition. Rust effectively has inheritance within traits, where a trait can bound Self to implement any other trait.

Passing a vtable pointer next to a data pointer like descriped is similar to &dyn Trait (a type of fat pointer) in Rust, which is dynamic dispatch. The overhead would be minimal, but BorrowScript could potentially do what Rust generics do and monomorphise what it can at compile time. This could be an optimisation, rather than a guarantee, and the compiler could fall back on dynamic dispatch (passing a function table pointer) if monomorphisation isn't possible, such as if multiple types are needed in an array. A limitation of dynamic dispatch like this is that the size is unknown at compile-time, so these objects would need to be (perhaps automatically) stored on the heap (like Box<dyn Trait> in Rust).