Draft of a proposal for an Explicit Ownership Syntax

JS implicit copy or reference variable depend on thair type in function call. This new syntax essentially uniformize argument call behaviour. Moreover, it add an explicit syntax to change scope variable (eg.: for globals variables used by function of if blocks)
It:

  • Prevent hidden behaviour
  • Simplify syntax for varaible copy (structuredClone)
  • Better variable lifetime trace (possible optimisation for implementers)

Syntax

This syntax introduce 2 new keywords

  • move (move permanently a variable from a parent scope to a new child scope)
  • copy (copy a variable from a parent scope to a new child scope)

Referenced variable can't be moved to prevent access to released memory space

If function use a key word in the declaration it is mandatory at call

Notation

Basic usage

{
    //scope A
    const a = /* any */
    const b = /* any */
    
    foo(move a, copy b) {
        //scope B
        a //can be accessed
        b //can be accessed
    }
    
    a //reference error, a is not referenced
    b //can be accessed

    if (move b !== undefined) {
        foo(move b)
        b //reference error, b is not referenced
    }

    b //reference error, b is not referenced

}

Function declaration

function foo(move arg0, copy arg1, arg2) {
    //
}

const a, b, c = [1, 2, 3]

Keyword are mandatory if declared in function arguments

Respecting keywords

foo(move a, copy b, c) //ok

Copy to move "cast"

foo(copy a, copy b, c) //ok, copy encapsulate move for call

Move to copy "cast"

foo(move a, move b, c) //ok, move equivalent to copy for call

To keywordless declaration "cast"

foo(move a, copy b, copy c) //ok
foo(move a, copy b, move c) //ok

No keyword to move "cast"

foo(a, copy b, c) //throws, foo require ownership for arg0

No keyword to copy "cast"

foo(move a, b, c) //throws, foo require a copy for arg1 (ensure non mutation of b)

Rules

copy a is equivalent to structuredClone(a)
move a de-reference a to all the parents scopes

Examples

Copy data

const source = /* any */
const sourceCopy = copy source

If block

let value

if (move value = foo()) {
    //do stuff
}

value //out of scope

Legacy muting methods

const a = [1, 2, 3]

const b = (copy a).reverse()

//a is unchanged

Ressource access prevent

const ressource = /* */

function release(move ressource) {
    ressource.release()
}

release(move ressource)

//ressource not accessible anymore

Proposal: https://github.com/JOTSR/proposal_explicit_ownership_syntax`

I'm not sure what you mean - JS is always strictly pass by value for every type of value, see https://web.archive.org/web/20161005155047/http://www.jon-carlos.com/2013/is-javascript-call-by-value-or-call-by-reference/ for an explanation.

1 Like

Yes, primitive are passed bt value and object by reference, but since JS is not strictly typed you can pass an object ass argument and the execution will not throws on reference/value choice but on possibly type error. Here is a soluction to enforce a reference passing call (that also works for primitive). Moreover, it suggest a syntax to give non mutable reference, since third party function can mutate object and cause difficult to see behaviours.

No, objects are also only passed by value. "pass by reference", as the article explains, refers to direct assignment (and not property mutation).

2 Likes

This sounds fairly Rust inspired. While it would be awesome if we could bring some of these behaviors over to JavaScript, I don't think they translate well, and I don't think there's a practical way to make them translate. There's a few reasons for this:

  1. StructuredClone isn't a great general-purpose cloning algorithm. Indeed, I don't believe it's possible to create a good, safe, general-purpose cloning algorithm in JavaScript. For example, if I create a custom Map class, which I'll call MyMap, and I pass an instance of it into structuredClone, it will lose all of its methods and its private state will be inaccessible (I presume that's just thrown away as well). Any objects with functions can't be cloned either.
  2. Move semantics work great in Rust, because they're part of a whole system that can provide guarantees that, once you move ownership into a function, no one else can touch that resource. Every piece of code that has been written in Rust has been written in this framework where memory must be managed this way. This isn't the case in JavaScript. In JavScript, I could trivially bypass the guarantees of the move semantics by taking my resource stored in a local variable and putting it on, lets say, globalThis. Later, I "move" my local variable in a function, and lose my ability to use that local variable, but I still have access to the resource via globalThis, and I can still mutate it all I want. I used globalThis as an example, but it could be any object that's being stored in an outer scope, like at the module-level.

You could prevent issue 2 by layering on new rules, like, I must prove that I have full control of the resource, and there aren't references to it hanging out in other places. But, its really too late to do that sort of thing. My code might create a local variable, pass it into one third party library, then pass it into your newer library that has the "move" semantics (e.g. "const resource = ...; thirsPartyLib(resource); yourLib(move resource);`). These "move" semantics require me to prove that there isn't another mutable reference to this variable hanging around somewhere, but I have no way to prove this, because the first library could have very well captured the variable and stored it, and I can't do anything to prove that it hasn't done this.

I guess you can have the same behaviour currently with objects and proxies / getter, setters.

Setting the value of a property to null after the initial 'get'.

The structuredClone as alias was an example of the behaviour, but yes this algorithm is not suited for that. Can't a low level memory copy be used in runtime inmplementations ? Some Object seems to be hard to copy, maybe a call to new static Object method or Object.prototype named for example "toCopy" that be called by the keyword and that to be surgarged by specific structure and thorws else (the same with move and a "toMove"). Maybe a lazy copy that check ownership at property call or a proxy ?

For the move keyword, object that reference a non anonymous variable can be disallow (eg.: Type error, "var" as no ownership to "reference")

In this proposed deep-clone algorithm, how would it handle things like:

  • prototypes? If I have, for example, an instance of a userland MyUser class, and I deep-clone my instance, will it still use MyUser.prototype as its prototype? What if I deep clone MyUser itself, how would that behave?
  • Closures? if I clone a function that has captured variables from the outer scopes, will it also deep-clone those captured variables?
  • How would you handle scenarios where, for example, a userland class keeps track of all instances made of it in some static "instances" property. There's no reasonable way to make the deep clone understand that it needs to auto-add the instance to this static "instances" property.

In first, copying only refers to the "intrinsic" value, not a reference. So, a simple approach can be to:

  1. Primitives: copy the value
  2. Object:
    a. copy own properties and refers the prototype
    b. Throws if no ownership on a value (eg: parent scope value that have not be moved with move or copyied with copy to avoid performance leak, memory race and long recursion copy.
    c. Recurse from (1.)

Another implementation can be to, for example in V8, duplicate JS Object and recurse on all properties and elements and for JS Function copy all JS Object and JS Function in Context and recurse until global context (that can be make lazy). But it relieves on runtime implementation and not TC39 normalization I think.

As mentioned before, we can also, for Object, implicitly call a copy function (static of Object or in prototype) that can be surcharged.

The goal is to have a unique syntax to move or copy primitives or objects and declare mandatory explicit behavior for callers. In addition to normalize syntax of such manipulations it can also, in a second time and independently from TC39, allow optimization in engine and flow safety for js supersets (e.g. typescript)

I think it's worth giving a link to the original idea?

As for everything else:

Prevent hidden behavior

JS is pretty explicit. All plain types such as Number or Boolean passed by value (as in all languages). Everything else is passed by reference;

Simplify syntax for varaible copy (structuredClone).

structuredClone is a very specific thing, used very rarely. For example, for passing objects to WebWorker.

Better variable lifetime trace (possible optimisation for implementers)

JavaScript is GC-based Moreover, many optimizations like escape analysis don't need explicit lifetime marking at all.

All this would only add verbosity and increase the entry threshold, with no obvious benefits

Thanks for referencing this project but I didn't know this project when I post this proposal

For testing purpose here a simple live example to taste this syntax. For complexity reason it only support clone since move need to check scope tree structure at assignement and so need some work on the js compiler

I propose to solve this issue with a new cloner protocol. Snippet below, full code here

Object.prototype[Symbol.cloner] = function () {
	//Copy primitives and null except Symbol
	if (ClonablePrimitives.includes(typeof this) || this === null) {
		return this
	}

	//Throw if not a literal object
	if (Object.getPrototypeOf(this).constructor.name !== 'Object') {
		throw new CloneError(`${this} does not implement [Symbol.cloner]`)
	}

	const clone: Record<string, unknown> = {}

	for (const [property, descriptor] of Object.entries(
		Object.getOwnPropertyDescriptors(this)
	)) {
		try {
			//Recursive clone, not suited for circular references
			clone[property] = descriptor.value[Symbol.cloner]()
		} catch (e) {
			//@ts-ignore cause in Error
			throw new CloneError(`property [${property}] is not cloneable`, {
				cause: e,
			})
		}
	}

	return clone as unknown as typeof this
}

Anything put on Object.prototype won't work with null objects (Object.create(null), { __proto__: null }, etc), and is a nonstarter with the committee.

(Also, please don't ever install anything onto objects you don't own outside of a spec-compliant polyfill)

It is only a simple live exemple. It don't support all use case or pretend to explicitely describe a possible implementation, so null and undefined are not handled, Symbol.for neither and pimitives are cloned via their object instead of direct copy. A good demo should have been done by tweaking a js parser but it will take me a lot of time for a first taste of this idea.

I don't understand this remark, Is for the demo on "Map, Array, Date, ...". As it is only a live example it is not to intend any modification outside the scope of this proposal. (Maybe I miss the meaning as I'm not a native english speaker)

I don't see how you could introduce linear types in JS without requiring a new variable declaration to implement those semantics (let's call it linearLet). And that only solves the problem for holding an object reference. Let's assume there was a syntax addition to express move when invoking functions or returning results from a function, that leaves out property access, which includes method invocation. How do you prevent duplicating a reference to the property of an object held in a linearLet? And how do you allow method calls on these objects (after-all methods are just function values held in the object's property)? Even if you somehow can get the method and invoke it, does the method call have access to the object as this?

As I've stated in the other thread, I believe it's not possible to implement linear types in a dynamic language like JavaScript.

It is more for the Explicit Reference Syntax but for declaration with the & (read write ref) sigil you can declare an exclusive reference:

let &exclusive = new Object()
let otherRef = &exclusive //ref error, value has already a mutable refrence
let @readOnlyRef = &ref //ref error, can't ref a reference (in the same scope, you can pass it as arg)

With clone/move you can limit scope access

let obj = /* any */
let obj2 = /* any */
function(clone obj, move obj2) {
 //obj is a clone and can be released with function
 //obj2 is moved and can be released with function
}

If you mix the 2 syntax you can clone the referenced value (clone &exclusive clone the value, not the reference to it).

It already was discuss in the reference topic but if it what you said, you can't return a reference since is need a cast to a non ref type (du to return keyword) which is forbidden by the syntax. If you return a moved object, it is like a new object you've got from the function call since the original object is dereferenced.

As the object is cloned, there is no problem to call method, it is like a new declared object, the same for moved object but I don't see the issue since the object is not re-referenced in another scope. It has no influence on the lifetime.

let &exclusive = { foo: { bar: 'baz' } };

const foo = exclusive.foo; // is this allowed? If not, why not?

What does cloning an object with methods mean? Especially if these are closures.

const makeFooContainer = (foo) => ({
  getFoo: () => foo,
});

let &exclusive = makeFooContainer({ bar: 'baz'});

function doStuff(copied) {
  copied.getFoo().bar = 'changed';
}

doStuff(copy exclusive);
console.log(exclusive.getFoo().bar); // 'changed'
// internal state of "copied" object got mutated.

first "&" is a mandatory sigil so you have to write

let &exclusive = { foo: { bar: 'baz' } };

const foo = &exclusive.foo

then as foo is like an implicit read-write reference of &exclusive and reference is recursive in the sense that they are applied on object and all these props it raise a "ref error: one mutable ref allowed". As discused if the draft you can't reference an object that allready holding an explicit reference (a parse since the sigil is mandatory) or an implicit reference (at runtime) for ownership reason.

Cloning (and moving) are protocol to implement on object (since deep clone problematic was discussed). Standard global object as to provide an implementation and user defined object herit from "Object" but have to implement their logic. You can see an example of what it could look like (and test it on live example in my github)