gkz / LiveScript

LiveScript is a language which compiles to JavaScript. It has a straightforward mapping to JavaScript and allows you to write expressive code devoid of repetitive boilerplate. While LiveScript adds many features to assist in functional style programming, it also has many improvements for object oriented and imperative programming.
http://livescript.net
MIT License
2.31k stars 156 forks source link

Does LiveScript really need (to support) async/await? #1085

Closed ceremcem closed 4 years ago

ceremcem commented 4 years ago

As far as I could understand, async/await is there for adding syntactic sugar that makes the code that heavily uses Promises readable.

LiveScript has backcalls. We can write thousand of callbacks in the same column, no callback hell. Backcalls are like "Just replace = with <- and you now have await".

Is async/await a solution to a problem in the Javascript world that Livescript has solved it before? Is async/await pointless in Livescript?

rhendric commented 4 years ago

Backcalls aren't quite as powerful as promises. async/await integrates with control flow structures in a way that backcalls don't and probably never will. Consider an await inside a loop, or inside a try/catch/finally expression, and think about the hoops you'd need to jump through to translate that into equivalent code that uses callbacks. LS's backcalls are intentionally very simple; the transformations applied by JS implementations to async/await can get considerably more complicated, and why duplicate that work in LS?

ceremcem commented 4 years ago

Consider an await inside a loop

That's absolutely a necessity. I'm working around this by the following <~ :lo(op) ~> approach:

sleep = (ms, f) -> set-timeout f, ms
debug-log = -> console.log ...[(new Date! .toISOString!), ...arguments]

i = 3
debug-log "start"
<~ sleep 1000ms 
<~ :lo(op) ~>
    debug-log "hi #{i}"
    i := i - 1
    if i is 0
        return op! # break
    <~ sleep 1000ms
    lo(op)
<~ sleep 500ms 
debug-log "end of loop."

#  Outputs:
# 2019-09-17T02:25:56.952Z start
# 2019-09-17T02:25:57.953Z hi 3
# 2019-09-17T02:25:58.954Z hi 2
# 2019-09-17T02:25:59.955Z hi 1
# 2019-09-17T02:26:00.457Z end of loop.

or inside a try/catch/finally expression

Well, I've no proposal for that as I never needed such thing. I'll think about it.

It turns out I re-invented Promises before I get to know them, with a slightly different approach: https://github.com/ceremcem/signal. That's why I never needed Promises hence the async/await.

Anyway I think I got the idea. If we have a chance to use a "default tool" for a job, you say it would probably make the job done simpler and simpler is better.

ceremcem commented 4 years ago

One more headache is if / else statements with async calls:

if doc?
    my-doc.attachments.push doc 
else 
    # fetch the document `doc` and then make the attachment 
    err, res <~ db.get 'doc'
    my-doc.attachments.push res 
err, res <~ db2.put my-doc 
console.log "my-doc has been saved"

Obviously above code doesn't work as it won't wait for db.get 'doc' phase.

My workaround was using my SignalBranch:

branch = new SignalBranch
if doc?
    my-doc.attachments.push doc 
else 
    # fetch the document `doc` and then make the attachment 
    my-signal = branch.add!
    err, res <~ db.get 'doc'
    my-doc.attachments.push res 
    my-signal.go!
<~ branch.joined 
err, res <~ db2.put my-doc 
console.log "my-doc has been saved"

Of course, it would be simpler to write it like so (I'm not sure about the syntax, I'm not familiar with await/async):

unless doc?
    # fetch the document `doc` and then make the attachment 
    doc = await db.get 'doc'
my-doc.attachments.push doc 
err, res <~ db2.put my-doc 
console.log "my-doc has been saved"
rhendric commented 4 years ago

The arguments for async/await syntax go even deeper than simplicity. Consider your last pair of code snippets:

branch = new SignalBranch
if doc?
    my-doc.attachments.push doc 
else 
    # fetch the document `doc` and then make the attachment 
    my-signal = branch.add!
    err, res <~ db.get 'doc'
    my-doc.attachments.push res 
    my-signal.go!
<~ branch.joined 
err, res <~ db2.put my-doc 
console.log "my-doc has been saved"

I realize this is a simplified example for purpose of discussion, but nevertheless: this code has a problem. If the db.get 'doc' call ‘returns’ an err, this code doesn't report it and happily goes on to push my-doc into db2 anyway. Now, of course, it's pretty easy to go in and add error-handling code... but the point is, when using await:

unless doc?
    # fetch the document `doc` and then make the attachment 
    doc = await db.get 'doc'
my-doc.attachments.push doc 
await db2.put my-doc
console.log "my-doc has been saved"

you don't have to. In this version, an error in db.get (or, for that matter, in db2.put) will prevent the subsequent statements from running—just like with a synchronous call—and be captured in the enclosing Promise, hopefully to be handled by either a regular try/catch or by a .then on-success, on-error or .catch on-error call directly on the Promise.

So not only does await mean writing less code, it also means handling errors for each asynchronous call isn't one more thing you have to remember to do correctly. Making fewer traps for programmers to fall into is a pretty strong reason to prefer await, even if it didn't also make your code simpler.

ceremcem commented 4 years ago

For being a reference to the people like me (and to myself), here is the rewrite of above "workarounds":

sleep = (ms) ->
  new Promise -> setTimeout it, ms

db = 
  get: (id) ->> 
    await sleep 2000ms 
    if id is 'opps'
      throw new Error "There is something happened unexpected"
    else if id.length > 5
      throw new Error "document #id not found."
    return {id, value: "the value of #id"}

  put: (doc) ->>
    await sleep 2000ms 
    if id is 'opps'
      throw new Error "There is something happened unexpected"
    else if id.length > 5
      throw new Error "document #doc.id can not be saved."
    return {doc.id, attachment-count: doc.attachments.length}

do ->> 
  try
    my-doc = {id: "my", attachments: []}
    unless doc?
      console.log "Fetching the document"
      doc = await db.get 'opps'
    my-doc.attachments.push doc 
    console.log "Saving my-doc: ", my-doc
    await db.put my-doc 
    console.log "my-doc put status:", err, res  
  catch
    console.error "Something went wrong: ", e 

do ->>
  console.log "hello"
  for to 5 when .. < 4
    console.log "there #{..}"
    await sleep 1000ms 
  console.log "done."
vendethiel commented 4 years ago

Here I guess it doesn't matter, however in general it's probably better not to use await in loops, since you won't get batching at all.

ceremcem commented 4 years ago

@vendethiel Sorry, I couldn't understand. What do you mean by "getting batching"?

vendethiel commented 4 years ago

Sorry. I mean you get sequential and not parallel requests.

pages = []
for from 1 to 5
  pages.push await get-page ..
# queries page 1, waits for the request to be complete, queries page 2, waits, ...

# VS

pages <- Promise.all [1 to 5]map get-page
# starts the 5 queries at once

Depends on what you want...

ceremcem commented 4 years ago

Oh, yes, I got it now. This sequential pattern is useful while implementing "reconnect/retry" pattern:

for from 1 to 3
  try 
    ok = await serial.send-and-ack "my data"
    break
unless ok 
  throw new Error "We couldn't send the data"
determin1st commented 4 years ago

@ceremcem

i've rewritten your example with this:

# shared data
data =
  error: false
  doc: null

# async controller
branch = new SignalBranch {
  step1: !->
    if data.doc
      myDoc.attachments.push data.doc
      @step2!
    else
      err, res <~ db.get 'doc'
      if not err
        myDoc.attachments.push res
        @step2!
      else
        @error!

  step2: !->
    err, res <~ db2.put myDoc
    if not err
      console.log("my-doc has been saved");
    else
      @error!

  error: !->
    data.error = true
    console.log 'error occured'
}

so it's longer, but more readable, what you think? 8)

determin1st commented 4 years ago

some links about this topic (in general):

https://esdiscuss.org/topic/alternative-to-promise

https://github.com/kriskowal/q/blob/v1/design/README.md

ceremcem commented 4 years ago

@determin1st

so it's longer, but more readable, what you think? 8)

Sadly no, I don't find this more readable, because sometimes I use lots and lots of nested SignalBranch. Something like this:

b1 = new SignalBranch 
b1s1 = b1.add!
b1s2 = b1.add!
my-physical-button.on 'pressed', (button-num) -> 
  b1s2.go err=null, button-num
if something
  err, res <~ foo
  b1s1.go err, res 
else 
  b2 = new SignalBranch 
  if mydoc 
    doc = mydoc
  else 
    b2s1 = b2.add!
    err, res <~ bar 
    b2s1.go err, res 
  err, signals <~ b2.joined
  b1s1.go err, myresult
err, signals <~ b1.joined
# result will be delayed till the button is pressed

Such a logic would be hard to read for me if it's configured like in your suggestion. Moreover, I generally try to avoid from using global variables (hence the := operator) because maintaining a global variable makes the code harder to reason about.

I read the Action.js and I noticed the "blazing fast" claim. I'll try the benchmark locally ASAP.

determin1st commented 4 years ago

Again, re-created your example (I belive I saved the logic):

data =
  s1: false
  s2: false

new SignalBranch {
  first: !->
    my-physical-button.on 'pressed', (button-num) ~>
      data.s2 = true
      @check!
    if something
      err, res <~ foo
      data.s1 = true
      @check!
    else
      @second!

  second: !->
    if mydoc
      doc = mydoc
      data.s1 = true
      @check!
    else
      err, res <~ bar
      data.s1 = true
      @check!

  check: !->
    if data.s1 and data.s2
      @complete!

  complete: !->
    # result will be delayed till the button is pressed
}

Oke, ive got the point - We use what We use. 8)

You may avoid := by putting the data into generic object, so, the assignment will be data.propertyName = value.

Also, it's not neccessary to be global - just some upper scope.