logo

Hylo Programming Language

Posted on: 2023-01-15

The Hylo language was previously known as Val.

Conclusion

Lots to like here. This is a language that embraces mutable value semantics. It's still in a pretty early state, without having a fully working compiler yet.

The ownership mechanism they use looks pretty interesting.

It feels as if I would still want to have "escape hatches" such that mechanisms other than value semantics are available. It will also take more thought on how to write large systems using mostly value semantics would work.

The value semantics and ownership models used seem very amenable to targets other than CPU.

Interesting

Good

Maybe good?

Not sure

Bad

Ugly

Exclusion Summary

Discussion

A module may also define an ABI resilience boundary, within which code and details such as type layout are never encoded into other compiled modules (e.g. via inlining). source

Nice to have the control.

Some operations are said to be consuming, because they force-end the lifetime of a binding. In other words, they must be the last use of the consumed binding. For example, assigning into a var binding consumes the source of the assignment. Similarly, tuple initialization consumes the source values. source

Huh - very linear typey. Note that here though the example is operating on float types that are immutable, and yet they are still "consumed".

I quite like the use of & to indicate use mutability.

Does use .N to access tuple members. Whilst a little odd seems better than zigs use of .@"N" syntax. I suppose there might be some ambiguity on seeing N.1.0, that the .1.0, is it two float literals? If we don't know what N is then that could be a problem.

public fun main() {
  let numbers = [0, 1, 2, 3, 4]
  print(numbers[2 ..< 4]) // [2, 3]
}

Creates a slice. The 2 ..< 4 syntax is a little unusual. It does make more apparent (than say ...) that the range is non-inclusive of the last index.

Has Optional<Optional<T>> distinct from Optional<T> elsewhere this has been described as being the superior arrangement.

Has argument labels a bit like objective C (and probably swift). Hylo doesn't support type based function overloading, because of this labelling mechanism.

Requires returned values from functions to be used by default. Has 4 styles of parameter passing to functions

To create a copy requires an explicit .copy()

The let convention does not transfer ownership of the argument to the callee, meaning, for example, that without first copying it, a let parameter can't be returned, or stored anywhere that outlives the call.

Has methods and uses self as the this equivalent. Does a C++ type thing of placing the modifiers around this after the method.

Method bundles allow for multiple implementations, depending on the parameter passing style for self. This looks kinda interesting and deserves more thought.

Supports closures, it is necessary to describe certain types of binding captures.

A subscript is a resuable piece of code that yields the value of an object, or part thereof. It operates very similarly to a function, but rather than returning a value to its caller, it temporarily yields control for the caller to access the yielded value. source

Called with '[]' not with '()' as with functions.

It appears the 'yield'ing is a little like continuations. The yield returns a result to the caller, but the rest of the subscript is still executed when scope is left, at least that's how it appears in the example

subscript min(_ x: Int, _ y: Int): Int {
  print("enter")
  yield if y < x { y } else { x }
  print("leave")
}
​
public fun main() {
  let one = 1
  let two = 2
​
  let z = min[one, two] // enter
  print(z)              // 1
                        // leave
}

Subscripts can be methods, which can be named or not. Also allows bundles for differnent styles.

Adds yielded as a way or marking parameters, where the usage style can be infered.

The computed properties section is interesting

type Angle {
  public var radians: Double
  public memberwise init
  
  public property degrees: Double {
    let {
      radians * 180.0 / Double.pi
    }
    inout {
      var d = radians * 180.0 / Double.pi
      yield &d
      radians = d * Double.pi / 180.0
    }
    set {
      radians = new_value * Double.pi / 180.0
    }
  }
}

The property is a "subscript" "method bundle". For the inout version, d is calculated in degrees, and yielded (returned) and the new value set (after the yield is returned). Does this mean the d after the yield becomes the in part?

Interesting that can use back ticked names as identifiers, allowing the use of keywords as identifiers. The perhaps nice thing about this, is that might relieve some anxiety around using keywords the user might want to use.

A trait is a collection of requirements on a type. Note: A trait is not a type but it may form a type if it is part of an existential type.

Example...

trait Shape {
  static fun name() -> String
  fun draw(to: inout Canvas)
}

Has associated type, which can specify size requirements as well as other other type constraints

trait Generator {
  type Element: Copyable
  inout fun next() -> (element: Element, done: Bool)
}

Has extension but not yet entirely clear how that works.

Similarly has conformance keyword which appears to be an implementation of a conformance. It appears possible to make conformances conditional with where clauses.

Has some surprising rules around conformance and traits...

import M

public type A {}
conformance A: M.T {} // OK: conformance is private

type B: M.T {}        // OK: 'B' is not exposed outside of the module

public type C: M.T {} // error: cannot expose conformance to imported trait 'M.T'

Not clear why Cs conformance being public is not possible.

fun borrow<A>(_ thing: inout T) {
  let a = [thing]         // lifetime of 'thing' ends here
  print(a)
  thing = a.remove_last() // new lifetime of 'thing' starts here.
}

The lifetime of thing ends when a is initialized because construction of an array literal consumes the literal's elements. A new lifetime starts before borrow returns as the call to Array.remove_last produces an independent value.

Feels linear type-like.

Introduces by keyword

subscript min<T, E>(_ a: T, _ b: T, by comparator: [E](T, T) -> Bool): Int {
  let   { yield if comparator(a, b) { a } else { b } }
  inout { yield &(if comparator(a, b) { a } else { b }) }
  sink  { return if comparator(a, b) { a } else { b } }
  set   { if comparator(a, b) { a = new_value } else { b = new_value } }
}

The subscript example shows a generator in an array

extension Array where Element: Copyable {

  subscript generator(from start: Int): var [some _] () inout -> Maybe<Element> {
    fun[let self, sink var i = start]() {
      if i < self.count() {
        defer { i+= 1 }
        return self[i].copy()
      } else {
        return nil
      }
    }
  }
}

Needs more thought into why this is the way it is... quite alot going on there.

Looks like it has a way to define precedence. Interestingly it's not at the level of operators, but operator types.

Interesting addition of where clause to for loop. It's appeal is perhaps not having to have if ... continue as first statement. In example given can also filter on type.

Use of lower_snake for names of things (like make_iterator), hmm not keen.

Like swift uses prefix, infix and postfix to distinguish operator types, without the need for C++ odd extra parameter hack.

Read Language Spec

From the related paper

Swiftlet applies copy-on-write on arrays only, as structures are allocated inline, enabling a different set of optimizations to elimitate unnecessary copies. One limitation of our approach, though, stems from its interaction with the implementation of inout arguments (Section 5). Recall that an inout argument is passed as a (possibly interior) pointer. Hence, the callee has no way to determine whether or not that pointer refers to a value inside of a shared buffer. As a result, the caller is compelled to copy non-unique storage defensively.

If the reference count is 1, but is contained in another array that is shared, if we allowed mutation of the contained value, we would mutate the contents of the outer array. To work around, the inner array has to be defensively copied. It will be unique/non shared, but that copy could be costly.

In the performance analysis swiftlet is slower than swift. It is argued that the majority of that is around swift having optimization passes that simplify the reference counting for copy on write. If the compiler can show an array is unique, it is not necessary to check the reference count every iteration.

This is interesting in that implies either some generalized way to indicate this behavior to the compiler (as a trait, detection by heuristic) or that it works for some special types (say a built in array type). The former seems preferable.

It is perhaps worth thinking about how reference counting might work in a multi-threaded environment. Thankfully the value semantics can be helpful. We'd prefer reference counting to not be atomic in general, because of the considerable increased costs, and reduced optimization opportunities (the compiler can't reason about what another thread might do in general). The language Nim takes this stance. We can add a function (generated at compile time), that when given a type can produce a copy that is guarenteed to be unique. It can then be passed between threads. Alternatively if a value is being "given up" to another thread it can be moved.

Questions

Other links