-
Notifications
You must be signed in to change notification settings - Fork 28
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
Recursive custom blocks don't automatically yield #198
Comments
Okay, so two bits of important implementation context:
|
The first "totally effective approach" was as bog terrible as it could be. Here's the short example we wrote. try {
this.callStack.push(null);
while (${condition}) {
${substack}
${warp ? "" : "yield;"}
}
} finally {
this.callStack.pop();
} The big example code, in this style.*myCoolScript() {
try {
this.callStack.push(null);
if ('foo' !== 'bar') {
yield* this.sayAndWait('That makes sense!', 2);
if (this.callStack.recursive(this.doSomethingElse)) {
yield;
}
try {
this.callStack.push(this.doSomethingElse);
yield* this.doSomethingElse();
} finally {
this.callStack.pop();
}
} else {
yield* this.thinkAndWait('Oh dear, oh dear', 3);
try {
this.callStack.push(null);
while (this.fruits.length > 0) {
if (this.callStack.recursive(this.eatFruit)) {
yield;
}
try {
this.callStack.push(this.eatFruit);
yield* this.eatFruit(this.fruits[Math.floor(Math.random() * this.fruits.length)]);
} finally {
this.callStack.pop();
}
}
} finally {
this.callStack.pop();
}
}
} finally {
this.callStack.pop();
}
}
*doSomethingElse() {
if (this.callStack.recursive(this.myCoolScript)) {
yield;
}
try {
this.callStack.push(this.myCoolScript);
yield* this.myCoolScript();
} finally {
this.callStack.pop();
}
}
*eatFruit(fruit) {
try {
this.callStack.push(null);
if (Math.random() > 0.25) {
try {
this.callStack.push(null);
if (Math.random() > 0.25 /* no really */) {
if (this.callStack.recursive(this.myCoolScript)) {
yield;
}
try {
this.callStack.push(this.myCoolScript);
yield* this.myCoolScript();
} finally {
this.callStack.pop();
}
}
} finally {
this.callStack.pop();
}
}
} finally {
this.callStack.pop();
}
} This is the worst thing ever, but there are some useful things to note.
|
This approach is actually the first one we came up with at all. It's pretty simple, but it doesn't quite work, because we didn't realize normal control structures count as stack layers at this point. yield* this.enter(this.${procName}, ${procArgs});
// wherein
*enter(fn, ...args) {
if (this.callStack.recursive(fn)) {
yield;
}
this.callStack.push(fn);
try {
yield* fn(...args);
} finally {
this.callStack.pop();
}
} Full example, which doesn't account for normal control structures*myCoolScript() {
if ('foo' !== 'bar') {
yield* this.sayAndWait('That makes sense!', 2);
yield* this.enter(this.doSomethingElse);
} else {
yield* this.thinkAndWait('Oh dear, oh dear', 3);
while (this.fruits.length > 0) {
yield* this.enter(this.eatFruit, this.fruits[Math.floor(Math.random() * this.fruits.length)]);
}
}
}
*doSomethingElse() {
yield* this.enter(this.myCoolScript);
}
*eatFruit(fruit) {
if (Math.random() > 0.25) {
if (Math.random() > 0.25 /* no really */) {
yield* this.enter(this.myCoolScript);
}
}
} That isn't so bad, is it? Too bad it won't get much better from here...
|
This approach uses decorators! Wow! Well, we can't use decorators yet, and we probably won't be able to until 2029, or unless the Leopard editor starts embedding TypeScript or Babel. (Sure that's possible, but let's just call this imaginary for now.) class Sprite1 extends Sprite {
@recursionYields
*myCustomScript() {
...;
yield* this.myCustomScript();
}
} Full example. Would you believe this still doesn't handle normal control structures?@recursionYields
*myCoolScript() {
if ('foo' !== 'bar') {
yield* this.sayAndWait('That makes sense!', 2);
yield* this.doSomethingElse();
} else {
yield* this.thinkAndWait('Oh dear, oh dear', 3);
while (this.fruits.length > 0) {
yield* this.eatFruit(this.fruits[Math.floor(Math.random() * this.fruits.length)]);
}
}
}
@recursionYields
*doSomethingElse() {
yield* this.myCoolScript();
}
@recursionYields
*eatFruit(fruit) {
if (Math.random() > 0.25) {
if (Math.random() > 0.25 /* no really */) {
yield* this.myCoolScript();
}
}
} This is way too sexy, and we'd love it if it dealt with normal control structures. In fact, if we don't care about being overeager with automatic recursive yields (and had Babel access LOL), then this is a totally workable approach! But it doesn't deal with control structures, so it just won't work for perfect compatibility. Boo hoo. |
This is exactly the same approach as above, but with setup in the constructor instead. Boring, but technically perfectly fine. class Sprite1 extends Sprite {
constructor(...) {
...
this.myCustomScript = this.yieldWhenRecursive(this.myCustomScript);
// or a syntax sugar form
this.yieldWhenRecursive('myCustomScript');
// or more likely, but begrudgingly
this.prepareCustomScript('myCustomScript');
}
} Full example, still no normal control structure handling.constructor(...) {
...
// option #1
this.myCoolScript = this.yieldWhenRecursive(this.myCoolScript);
this.doSomethingElse = this.yieldWhenRecursive(this.doSomethingElse);
this.eatFruit = this.yieldWhenRecursive(this.eatFruit);
// option #2
this.yieldWhenRecursive('myCoolScript');
this.yieldWhenRecursive('doSomethingElse');
this.yieldWhenRecursive('eatFruit');
// option #3
this.prepareCustomScript('myCoolScript');
this.prepareCustomScript('doSomethingElse');
this.prepareCustomScript('eatFruit');
}
*myCoolScript() {
if ('foo' !== 'bar') {
yield* this.sayAndWait('That makes sense!', 2);
yield* this.doSomethingElse();
} else {
yield* this.thinkAndWait('Oh dear, oh dear', 3);
while (this.fruits.length > 0) {
yield* this.eatFruit(this.fruits[Math.floor(Math.random() * this.fruits.length)]);
}
}
}
*doSomethingElse() {
yield* this.myCoolScript();
}
*eatFruit(fruit) {
if (Math.random() > 0.25) {
if (Math.random() > 0.25 /* no really */) {
yield* this.myCoolScript();
}
}
} It has the same problems as above, i.e. it's OK if we don't care about being overeager, but not workable for perfect compatibility. |
Here's approximately the final syntax we came up with. This handles normal control structures. (Finally!) *doSomethingRecursive(x, n) {
if (n > 0) {
if (x > 20) {
yield* this.recurse(+2, this.whatever)(x);
} else {
yield* this.recurse(+2, this.whateverElse)(x);
}
yield* this.recurse(+1, this.doSomethingRecursive)(n - 1);
}
}
// wherein
recurse(depth, procedure) { /* note: not a generator */
const bound = procedure.bind(this);
const callStack = this.callStack;
return function*(...args) => { /* note: this is a generator */
for (let i = 0; i < depth; i++) {
callStack.push(null);
}
if (callStack.recursive(procedure)) {
try {
yield;
} catch (error) {
// An error during the initial yield means we should not continue
// to evaluate the procedure. But we already pushed those "blank"
// layers representing normal control structures, so we need to
// pop those off to clean up after ourselves (before passing the
// error along).
for (let i = 0; i < depth; i++) {
callStack.pop();
}
throw error;
}
}
callStack.push(procedure);
try {
yield* bound(...args);
} finally {
callStack.pop();
for (let i = 0; i < depth; i++) {
callStack.pop();
}
}
};
} Full example, in good working order.*myCoolScript() {
if ('foo' !== 'bar') {
yield* this.sayAndWait('That makes sense!', 2);
yield* this.recurse(+1, this.doSomethingElse)();
} else {
yield* this.thinkAndWait('Oh dear, oh dear', 3);
while (this.fruits.length > 0) {
yield* this.recurse(+2, this.eatFruit)(this.fruits[Math.floor(Math.random() * this.fruits.length)]);
}
}
}
*doSomethingElse() {
yield* this.recurse(+1, this.myCoolScript)();
}
*eatFruit(fruit) {
if (Math.random() > 0.25) {
if (Math.random() > 0.25 /* no really */) {
yield* this.recurse(+2, this.myCoolScript)();
}
}
} Notes on this one:
|
That's the last real example, but we have one more short code blurb to share: *doSomethingRecursive(x, n) {
if (n <= 0) {
return;
}
// `whatever` and `whateverElse` can never possibly route
// to a nested `doSomethingRecursive`, i.e. the graph from
// this point deeper is completely separate. there is thus
// no need for a `this.recurse()` call.
if (x > 20) {
yield* this.whatever(x);
} else {
yield* this.whateverElse(x);
}
yield* this.recurse(0, this.doSomethingRecursive)(n - 1);
} Routing? Graphing!? What is this! Well, we're pretty sure we can statically create a nice and simple static graph of which custom blocks run which other custom blocks. (Or themselves.) If we detect cycles at all (setting aside detecting the "depth" of these cycles for simplicity's sake), then within those cycles, procedure calls need to use The caveat is that if the custom block you're entering is part of a totally different cycle, then unfortunately, we still need to use |
OK, that's everything. We leave this conversation open and mostly free of our own opinions, although in messaging we did tell you we'll probably agree with the general directions you're thinking, lol. Interested to hear your thoughts! Please feel welcomed to ask questions if anything is unclear. |
Super simple demo project: Recursive diamond animation!
Up to five "stack layers" deep, Scratch considers the possibility of a recursive procedure call, — that is, a custom block that's been behaviorally nested inside of its own definition, directly or indirectly! — and if so, it performs a yield, which may for example wait for a screen refresh.
The sneaky thing is in the definition of "stack layer". In Scratch, a stack layer is any level of evaluation nesting. It's sort of like the JavaScript call stack, but because everything in Scratch is a block, even basic control structures push a new stack layer.
Imagine something like this:
Now this is terrible to read, but don't let that scare you away just yet! It's just to explain what Scratch is seeing. We're not Scratch, so we can make a slightly nicer abstraction out of this, but first we have to understand what Scratch is doing.
Let's imagine we follow the path into
this.doSomethingElse()
because'foo'
does in fact!== 'bar'
. The actual execution order, which the JavaScript or Scratch runtime would take, looks like this:this.callStack.push({why: 'myCoolScript'});
» Stack: [myCoolScript]this.callStack.push({why: 'if'});
» Stack: [myCoolScript, if]yield* this.sayAndWait('That makes sense!', 2);
» outputyield* this.doSomethingElse();
» enter custom block!this.callStack.push({why: 'doSomethingElse'});
» Stack: [myCoolScript, if, doSomethingElse]yield* this.myCoolScript();
» enter custom block!this.callStack.push({why: 'myCoolScript'});
» Stack: [myCoolScript, if, doSomethingElse, myCoolScript]So, right before Scratch begins executing the contents of any custom block (steps 4 and 6 above), it checks out the top five layers of the stack list. If any of those layers the custom block which it's in the process of starting, Scratch inserts a yield (possibly waiting for a screen refresh, etc).
At step 4, it's entering
doSomethingElse
. The stack is[myCoolScript, if]
. The top five layers of that stack are also just[myCoolScript, if]
. That doesn't includedoSomethingElse
, so Scratch doesn't insert a yield here. (It just begins executing the script's contents immediately.)At step 6, it's entering
myCoolScript
. The stack is[myCoolScript, if, doSomethingElse]
. The top five layers of that stack are also just[myCoolScript, if, doSomethingElse]
, and of course, that includesmyCoolScript
... so it inserts a yield, here.OK, let's say the sky is falling!! Now
'foo' === 'bar'
! What happens this time!?this.callStack.push({why: 'myCoolScript'});
» Stack: [myCoolScript]this.callStack.push({why: 'if-else'});
» Stack: [myCoolScript, if-else]yield* this.thinkAndWait('Oh dear, oh dear', 3);
» outputthis.callStack.push({why: 'while'});
» Stack: [myCoolScript, if-else, while] (assuming we have any fruit, of course ✨)yield* this.eatFruit(this.fruits[...]);
» enter custom block!this.callStack.push({why: 'eatFruit'});
» Stack: [myCoolScript, if-else, while, eatFruit]this.callStack.push({why: 'if'});
» Stack: [myCoolScript, if-else, while, eatFruit, if] (assuming we're lucky)this.callStack.push({why: 'if'});
» Stack: [myCoolScript, if-else, while, eatFruit, if, if] (assuming we're lucky again)yield* this.myCoolScript();
» enter custom block!this.callStack.push({why: 'myCoolScript'});
» Stack: [myCoolScript, if-else, while, eatFruit, if, if, myCoolScript]The same recursion rule applies, of course, during steps 5 and 9 here.
Step 5 is just like before - no
eatFruit
on the stack, so no yield.But step 9 is more interesting. The stack is
[myCoolScript, if-else, while, eatFruit, if, if]
. The top five layers of this, though, are only[if-else, while, eatFruit, if, if]
! Even though we're recursing intomyCoolScript
, which is on the stack, it's too far away from the top, and Scratch never sees it. So... it doesn't insert a yield here!See how that only happens because of how far we were nested inside ifs and if-elses and other custom blocks? Those simple control strucures really made a difference—one that could have a real impact, depending how badly a project turns out to depend on Scratch's behavior.
OK, we'll reply to this with a couple syntactical options we've considered, but here's just the summary of Scratch's behavior, to start!
The text was updated successfully, but these errors were encountered: