creationix / jack.rs

An implementation of the Jack scripting language in Rust
MIT License
2 stars 0 forks source link

What if scope was an object. #1

Open creationix opened 8 years ago

creationix commented 8 years ago

I think a scripting language with first-class scope objects would be neat. This would combine the idea of scope in closures with objects into a single construct. For example, the following in JS:

var tim = {
  name: "Tim",
  age: 33,
  isProgrammer: true
};

Could be rewritten if JS has first-class scopes that evaluated to themselves.

var tim = {
  var name = "Tim";
  var age = 33;
  var isProgrammer = true;
};

Now combine this with some language tweaks (remove semicolons, assume local for new vars):

tim = {
  name = "Tim"
  age = 33
  isProgrammer = true
}

There is now zero difference between creating local variables and creating properties on objects. This also makes for a very nice configuration language.

creationix commented 8 years ago

Continuing on this thought, suppose we had lexical closure like JS has. This would mean that our objects would inherit from their parent scope.

name = "Bob"
tim = {
  name = "Tim"
  age = 33
  isProgrammer = true
}
bob = {}
print(tim.name, bob.name) // Tim  Bob
creationix commented 8 years ago

We can inherit functions as well for shared methods.

function greet() {
  print("Hello from " + this.name)
}
tim.greet() // Hello from Tim
creationix commented 8 years ago

Now one of the cool features of ruby is the ability to enter a scope after it's been created. Let's explore that here as well by stealing the with keyword to have new meaning.

// These braces don't create a new scope, but reuse an existing one.
with tim {
  print(name) // Tim
  hasFun = true
}
print(tim.hasFun) // true
creationix commented 8 years ago

Let's also add first-class blocks. These will look like scope literals, but have an escape character at the front that means to keep them as code and to not execute yet.

myblock = #{
  print("run me!")
}

This can make for some really pretty DSLs.

require('tap')(#{
  test("this should work", #{
    assert(1 == 1, "it works")  
  })
})
creationix commented 8 years ago

Let's add a new with infix operator that calls the block in the left hand side with the value in the right hand side as the scope.

// Define a block (like a function, but has no formal parameters)
greet = #{
  print("Hello from " + name)
}
// Create a scope object
tim = {
  name = "Tim"
  age = 33
}
// Call the block passing in `tim` as the scope. using new `with` operator.
greet with tim
creationix commented 8 years ago

At this point, you're probably starting to have panic attacks thinking of all the ways these proposed changes can go wrong. Everything is public and mutable, everything is inherited, local scope changes at runtime. But let's continue on reducing features till something elegant comes out. We can always bolt on safety and best-practices at the end right?

creationix commented 8 years ago

We now have two systems for defining and calling functions. What if we drop the old JS syntax and semantics in favor of the new. That means no more function (...) { ...} or expr(...). We'll assume a global print block instead that accepts a message variable.

print with { message = "Hello World" }

Look at that, we have named arguments! Named is strictly safer and more explicit than positional arguments right? Maybe this rabbit hole isn't so crazy after all.

creationix commented 8 years ago

Now many functions have exactly one input and it would be a pain to name that one input all the time. How about we also allow non-objects to be used with with. We'll create a new pseudo variable self that refers to the current scope. Our print will use this instead.

print with "Hello World"
creationix commented 8 years ago

While we're changing things, how about pulling in some proposed ES7 features. I used to be a linux admin and love the pipe operator in bash. Let's add it! scope | block is the same as block with scope except the order is reversed.

"Hello World" | print

We can chain these as well. (note we've added implicit return)

doubleSay = #{ self + ', ' + self }
// head returns the first character in a string, tail returns the rest
capitalize = #{ (self | head | toUpperCase) + (self | tail) }
exclaim = #{ self + '!' }

"hello" | doubleSay | capitalize | exclaim | print // "Hello, hello!"
creationix commented 8 years ago

Now we hit our first design conflict. The implicit return added in the last example breaks the first examples where we had an ideal configuration language.

{
  name = "Tim"
}

This now evaluates to the string "Tim" since the last expression in the block was the assignment. We could say that assignments return the scope they were assigned to. This works well and only breaks the case where people use the (often considered bad) practice of using an assignment in the middle of an expression.

But if we want to keep the C semantics of assignment evaluating to the RHS, we just need to add a self at the end to return the scope.

{
  name = "Tim"
  age = 33
  self
}

So the tradeoff is to make config style declarative code less pleasant or to change well-known (but considered harmful) C semantics around assignment. I vote for changing the assignment semantics!

Remember:

A language that doesn't affect the way you think about programming, is not worth knowing. -- Alan Perlis

Also my primary target audience is people learning programming for the first time, so they won't be infected by learned habits from other languages.

creationix commented 8 years ago

So back to making this language safe to use. I'm not going to go so far as to add compile-time type checks. This is a highly dynamic scripting language after all. But we should add some run-time tools to help developers quickly and safely assert certain conditions. Making this part of the language opens up opportunities for future engines to optimize as well ad provides a better opportunity for good syntax.

The first problem is scope objects inherit everything. This fine as long as you don't use the stuff you're not supposed to use and aren't iterating on the objects, but some visibility control would be nice.

If we stick to public by default, then the config-file use case stays the same, but if we use private by default there are no surprises about what is exported. There is also the global or local by default question, but I think we'll stick to local-by-default. Otherwise nothing makes sense.

name = "Bob"
tim = { name = "Tim" }
bob = {}
bob.tim.name | print

Yes, this is possible. I doubt we wanted bob to have a tim property. We just wanted it to inherit name as a default.

creationix commented 8 years ago

While we're listing problems, there is another to ponder. Since we dropped normal lambda style function calls and instead use with or the pipe operator to call blocks, there is no way to mix closure variables with passed in variables. The with scope will replace the inherited local variables.

Should our scopes have multiple inheritance? Should we be able to declare certain variables that we wish to preserve from the outer scopes?

greet = {
  // A private variable
  greeting = "Hello "
  // return a block
  #{ greeting + name | print }
}
{ name = "Tim" } | greet

This example will not work since greeting will be undefined. Though we did find a clever way to create private variables, if only the closure worked.

creationix commented 8 years ago

One solution to the broken closures is to go back to a more JS-like system where self is the only injected variable (from with or pipe) and all local variables are lexical scope only. We'll even rename it to this to make the JS highlighter happy.

greet = {
  greeting = "Hello "
  #{ greeting + this.name | print }
}
{ name = "Tim" } | greet

This works, but we've reduced our blocks to JS functions with zero arguments and using this for all external inputs. This won't end well, there must be another way.

creationix commented 8 years ago

Actually on second thought, this isn't that bad. Will consider more...

creationix commented 8 years ago

With the self renamed to this and lexical scope preserved, the pipe examples look nicer:

doubleSay = #{ this + ', ' + this }
capitalize = #{ (this | head | toUpperCase) + (this | tail) }
exclaim = #{ this + '!' }

"hello" | doubleSay | capitalize | exclaim | print // "Hello, hello!"
creationix commented 8 years ago

Hmm, the lack of positional arguments makes the tap example quite interesting.

'tap' | require | #{
  "this should work" | this.test | #{
    1 == 1 | this.assert with "it works"
  }
}

I'm not sure about mixing block 'with' value with value '|' block.

creationix commented 8 years ago

So let's summarize the changes to the language so far.

First we made a nice config-file language with near ideal syntax for defining nested properties (JSON-like data)

Then we completely redid how functions and function calls worked.

Maybe we should have (> and <) instead of (| and with) since their semantics are exactly symmetrical?

creationix commented 8 years ago

So with > and < we could write the assert like:

 1 == 1 > this.assert < "it works"
// or
"it works" > this.assert < 1 == 1

depending on how we want to define order of operations.