Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consider turning Inko into a functional language #665

Closed
yorickpeterse opened this issue Dec 2, 2023 · 14 comments
Closed

Consider turning Inko into a functional language #665

yorickpeterse opened this issue Dec 2, 2023 · 14 comments
Labels
compiler Changes related to the compiler feature New things to add to Inko, such as a new standard library module runtime Changes related to the Rust-based runtime library std Changes related to the standard library

Comments

@yorickpeterse
Copy link
Collaborator

yorickpeterse commented Dec 2, 2023

Similar to #596, this is largely me thinking out loud, and to create a single place to gather notes.

Inko is currently mostly object-oriented. I say mostly, because we don't have message passing for regular objects (they're just method calls), no intercepting of messages, no inheritance, etc. Instead, we have objects with associated functions (= methods), and traits for composition, which basically "copy paste" methods into the objects.

A problem with our current setup is that expressing certain patterns requires a more complex/complicated type system, which I would like to avoid. A simple example is wanting to turn an Option[ref T] into Option[T]. This would require something like this:

impl Option[ref T] if T: Clone[T] {
  fn cloned -> Option[T] { ... }
}

The type-system doesn't support this as traits are implemented for classes, not arbitrary types. Supporting this requires extensive complex changes, likely affecting compile times and possibly resulting in ambiguous method lookups.

In contrast, using a standalone function/module method we can express this:

fn cloned[T: Clone[T]](option: ref Option[ref T]) -> Option[T] { ... }

When reopening classes we wouldn't need support for impl ... if either, as instead of this:

impl Array if T: Compare[T] {
  fn pub mut sort { ... }
}

We can just do this:

fn pub sort[T: Compare[T])(array: mut Array[T]) { ... }

In addition, function signatures no longer need to use fn mut and instead just use mut for the appropriate arguments. This makes async functions a little less confusing, as one can just write fn pub async foo { ... } instead of fn pub async mut { ... } and probably not remembering if it's fn pub async mut or fn pub mut async.

Another benefit is consistency: in the current setup it's not always clear if something should be an instance method on a dedicated type, a static method, or a module method. Combined with the type system limitations this leads to some inconsistencies, such as String.join being a static method but Iter.to_array being an instance method. This can also lead to throwaway objects that only exist for the purpose of grouping methods together, something quite pervasive in typical OO languages.

We'd also be able to change the field syntax from @name to value.name, as we'd no longer have the ambiguity that arises when a method and field exist with the same name.

One change we'd need to make is that processes need to be typed differently: the public facing type should be async T, while the interior bit (available only to async functions) would be typed as ref T or mut T accordingly. This way we don't need to rely on the current type private hack for regular process methods.

What I'm not sure about is how we'd handle modules, importing them, and types with the same name as modules. Take this hypothetical syntax for example:

# src/user.inko
module User {
  type User { name: String }
}

How do I import the module User vs the type User? import user.(User, User) followed by some clever compiler choice which symbol to use (e.g. the type in signatures, the module elsewhere)? import user.(User as UserMod, User)? Or maybe you just import user.User and the compiler figures it out for you? I'm not sure yet what the best choice is here.

A critical part of this change would be support for uniform function call syntax, i.e. value.function(arg) translates into function(value, arg). This is better for code completion, and removes the need for a dedicated pipelining operator.

I'm also not sure if/how we'd make functions first-class values. To make this happen we'd have to be able to refer to them, which conflicts with the call syntax allowing you to omit the use of parentheses. In addition, this would require heap allocating some sort of object such that an argument typed as fn -> X can receive both a function and a closure transparently, unless we want to specialize over that as well (likely complicating the specialization pass quite a bit).

Purity and immutability wouldn't be a goal or even desired, i.e. mut stays and the mutability semantics remain unchanged; it's largely a syntactical change, and a change in how functions are organized.

Long story short, this is something that I've been thinking about more and more over time, so I think it's time to start collecting proper notes.

Related links

@yorickpeterse yorickpeterse added feature New things to add to Inko, such as a new standard library module compiler Changes related to the compiler std Changes related to the standard library runtime Changes related to the Rust-based runtime library labels Dec 2, 2023
@yorickpeterse
Copy link
Collaborator Author

Another change we'd have to make: enum variants have static methods generated for them, such that TheEnum.Variant(foo) is just a method call. We'd need to change that to just construct the type directly, so we don't have to generate functions in a module that may already have functions with the same name.

@yorickpeterse
Copy link
Collaborator Author

Reading through gleam-lang/gleam#654 (comment), I agree with much of what is said there in regards to mapping modules to files being easier. This means no explicit module keyword, and module names would match file names. This also solves the import issues, as import std.string would import the string module, and import std.string.String would import the string type. This would require fewer changes as that's how the current system already works.

@yorickpeterse
Copy link
Collaborator Author

yorickpeterse commented Dec 2, 2023

If functions are scoped to modules instead of classes/types, re-opening them wouldn't make much sense, unless we allow impl string { ... } where string is a module. To be honest I think that's perfectly fine: re-opening classes exists because in an object-oriented language that's really your only way to add methods to a type. In a function language you'd just use a new module, e.g. std.option and std.option_extensions or something along those lines.

This means impl would only be used to implement traits for types.

@yorickpeterse
Copy link
Collaborator Author

Runtime/memory wise things would basically change like this:

Classes are turned into modules, and no longer concern themselves with allocation sizes or fields. When allocating a type we don't pass the class, but the size in bytes, and an async flag for processes (or we just use a different runtime function).

For implementing traits I'm not sure yet. If functions are scoped to modules, then implementing them for a type should expose them to the surrounding module. This can lead to naming conflicts if two different types in the same module implement the same trait, i.e:

# foo.inko
type User { ... }
type Admin { ... }

impl Foo for User {
  fn pub foo(user: ref User) { ... }
}

impl Foo for Admin {
  fn pub foo(user: ref Admin) { ... }
}

# bar.inko
import foo

foo.foo(...) # does this call Admin.foo or User.foo?

@yorickpeterse
Copy link
Collaborator Author

Scoping can also pose a challenge for droppers and destructors. Droppers are called $drop, and destructors drop. If you have multiple types in the same module, this leads to naming conflicts. For $drop we could generate a unique name, but for drop I'm not sure.

@kuchta
Copy link

kuchta commented Dec 2, 2023

Hi @yorickpeterse .
I'm very much in favor of turning inko into "functional" language with UFCS.
Having modules mapped to files is also easier to think about, that to have two separate concepts if you don't have a clear use for that. Regarding import syntax, is there a reason to have namespaced path, instead of string? i think strings are more flexible to work with in different environments, supports any UTF characters/relative/absolute/loader-specific resolutions etc...
Nim has quite similar design goals (UFCS etc..) and is doing quite fine even without traits/interfaces so far. It also uses something they call stropping to define 'Type bound operators' like destructors (=destroy) etc...

@kuchta
Copy link

kuchta commented Dec 2, 2023

@yorickpeterse What do you mean by Classes are turned into modules. Wouldn't it make them nullary singleton objects, or at least from the point of user? Is there any need for them in "functional" language?

@yorickpeterse
Copy link
Collaborator Author

@kuchta

Regarding import syntax, is there a reason to have namespaced path, instead of string? i think strings are more flexible to work with in different environments, supports any UTF characters/relative/absolute/loader-specific resolutions etc...

I specifically don't want to allow every character in module names, e.g. + or * in a module name makes no sense. I also feel using strings is a bit odd because they're only used for text, not identifiers of any kind. Changing the syntax or adding more features (e.g. relative imports) is out of scope.

What do you mean by Classes are turned into modules. Wouldn't it make them nullary singleton objects, or at least from the point of user? Is there any need for them in "functional" language?

What I meant is that the data structures used for classes internally are changed and renamed to work for modules, rather than coming up with something entirely new. In practise this comes down to renaming them, and removing some fields that may no longer be relevant. This is purely an internal/runtime thing though, not something user facing.

@kuchta
Copy link

kuchta commented Dec 2, 2023

@yorickpeterse I see, thank you for explaining.. Regarding imports and name conflicts, this might be of interest: https://narimiran.github.io/2019/07/01/nim-import.html

@yorickpeterse
Copy link
Collaborator Author

@kuchta Thanks, I'll take a look :)

@yorickpeterse
Copy link
Collaborator Author

Thinking out loud here:

If we always store functions in modules, and we want to allow multiple trait implementations for different types, we need explicit modules, such that we can do something like this:

module User {
  type User {
    name: String
    age: Int
  }

  impl ToString[User] for User {
    fn to_string(value: User) -> String {
      value.name
    }
  }
}

module Admin {
  type Admin {
    name: String
    age: Int
  }

  impl ToString[Admin] for User {
    fn to_string(value: Admin) -> String {
      value.name
    }
  }
}

# ----

import user.(User, Admin)

User.to_string(User { name: "Alice", age: 42 })
Admin.to_string(Admin { name: "Bob", age: 46 })

If each file translates to a module, then each type would need to go in its own file if we want to implement a trait for it, otherwise those trait's functions may conflict with the same implementation for a different type in the same file.

An alternative is that files do translate to modules, we don't have explicit modules, but we allow defining functions "inside" types like so:

type User {
  name: String
  age: Int
}

impl User {
  fn foobar {
    ...
  }
}

impl ToString[User] for User {
  fn to_string(value: User) -> String {
    value.name
  }
}

type Admin {
  name: String
  age: Int
}

impl ToString[Admin] for User {
  fn to_string(value: Admin) -> String {
    value.name
  }
}

# ----

import user.(User, Admin)

User.to_string(User { name: "Alice", age: 42 })
User.foobar
Admin.to_string(Admin { name: "Bob", age: 46 })

Here User.foobar is defined for the User type, meaning we can only use it using the syntax User.foobar and not e.g. user.foobar (where user is the module). This is basically what instance methods are now, but with an explicit self argument. Internally this would work by just mangling the function names.

This does however result in a similar split to what we have now: functions meant to operate on a particular object (e.g. User) here use an impl such that the function is "bound" to that type, while other random functions go in the module. The problem here is the inconsistency of "should it go in the type, or the module?". In fact, if we ignore static methods we basically have this exact setup, we just don't call it a functional language.

@yorickpeterse
Copy link
Collaborator Author

That last paragraph does reveal an interesting fact: if we ignore static methods, the way Inko types and methods are organized is basically just like a functional language with type classes, just with a different syntax and lacking first-class functions (something that likely would remain the case if I were to go along with the proposal of this issue).

@kuchta
Copy link

kuchta commented Dec 3, 2023

I think that using so much syntactic structure is a bit outdated. When I'm considering new language for small things to start with, I'm automaticly ruling out those with these properties. This problem could be solved by not using this identifier type imports (but disambiguation by full signature which you probably need anyway, if you want to support meaningfull UFCS) , as is mentioned in the above link.

@yorickpeterse
Copy link
Collaborator Author

I'm going to shelve this idea for the time being, seeing as how the changes wouldn't bring that many benefits after all while at the same time being highly disruptive for existing code and users.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
compiler Changes related to the compiler feature New things to add to Inko, such as a new standard library module runtime Changes related to the Rust-based runtime library std Changes related to the standard library
Projects
None yet
Development

No branches or pull requests

2 participants