2

While adding scriptability to my Mac program, I am struggling with the common programming problem of deleting items from an indexed array where the item indexes shift due to removal of items.

Let's say my app maintains a data store in which objects of type "Person" are stored. In the sdef, I've define the Cocoa Key allPersons to access these elements. My app declares an NSArray *allPersons.

That far, it works well. E.g, this script works well:

repeat with p in every person
  get name of p
end repeat

The problem starts when I want to support deletion of items, like this:

repeat with p in (get every person)
  delete p
end repeat

(I realize that I could just write "delete every person", which works fine, but I want to show how "repeat" makes things more complicated).

This does not work because AppleScript keep using the original item numbers to reference the items even after deleting some of them, which naturally shifts the items and their numbering.

So, considering we have 3 Persons, "Adam", "Bonny" and "Clyde", this will happen:

get every person
    --> {person 1, person 2, person 3}
delete person 1
delete person 2
delete person 3
    --> error number -1719 from person 3

After deleting item 1 (Adam), the other items get renumbered to item 1 and 2. The second iteration deletes item 2 (which is now Clyde), and the third iteration attempts to delete item 3, which doesn't exist any more at that point.

How do I solve this?

Can I force the scripting engine to not address the items by their index number but instead by their unique ID so that this won't happen?

Thomas Tempelmann
  • 11,045
  • 8
  • 74
  • 149
  • 1
    Have you considered providing name or uniqueID object specifiers for your Person objects? I think each `p` will be something like `person 5 in application "YourApp"`, so that deleting one will actually cause the remainder to implicitly change what they refer to. If you provide name or uniqueID specifiers, then `p` will be something like `person named "Joe Smith" of application "YourApp"` or `person id 429 of application "YourApp"`. Those will be stable as objects are deleted. – Ken Thomases Apr 12 '16 at 11:23
  • Yes, I have even specified in the sdef for the "Accessors" to use only the ID, not Index, but that doesn't change it at all, unless I did something wrong with the ID delivery. Gotta double check. I was also wrong about using a setter would not cause this - it does, too. I'll update my q now. – Thomas Tempelmann Apr 12 '16 at 11:29
  • 1
    Have you implemented `-valueWithUniqueID:inPropertyWithKey:`? Also, what is the result of just `get every person`? – Ken Thomases Apr 12 '16 at 12:01
  • Yes, I have implemented that, which never gets called. I also implemented `-indicesOfObjectsByEvaluatingObjectSpecifier:`, which only gets asked about NSPropertySpecifiers but not about Unique IDs. On the "get every person" I've updated my q again, throwing out all the indexed accessor methods as they make no difference to using just the plain NSArray property. See above. – Thomas Tempelmann Apr 12 '16 at 13:53
  • @KenThomases, thanks for getting me on the right track! – Thomas Tempelmann Apr 12 '16 at 14:20

3 Answers3

3

It's not your ObjC code, it's your misunderstanding of how repeat with VAR in EXPR loops work. (Not really your fault either: they're 1. counterintuitive, and 2. poorly explained.) When it first encounters your repeat statement, AppleScript sends your app a count event to get the number of items specified by EXPR, which in this case is an object specifier (query) that identifies all of the person elements in whatever. It then uses that information to generate its own sequence of by-index object specifiers, counting from 1 up to the result of the aforementioned count:

person 1 of whatever
person 2 of whatever
...
person N of whatever

What you need to realize is that an object specifier is a first-class query, not an object pointer (not that Apple tell you this either): it describes a request, not an object. Ignore the purloined jargon: Apple event IPC's nearest living relatives are RDBMSes, not Cocoa or SOAP or any of the OO messaging crud that modern developers so fixate on as The One True Way To Do... well, EVERYTHING.

It's only when that query is sent to your application in an Apple event that it's evaluated against the relational graph your Apple event IPC View-Controller – aka "Apple Event Object Model" – presents as an idealized, user-friendly representation of your Model's user date that it actually resolves to a specific Model object, or objects, with which the event handler should perform the requested operation.

Thus, when the delete command in your repeat loop tells your app to delete person 1 of whatever, all your remaining elements move down by one. But on the next iteration the repeat loop still generates the object specifier person 2 of whatever, which your script then sends off to your app, which resolves it to the second item in the collection – which was originally the third item, of course, until you shifted them all about.

Or, to borrow a phrase:

Nothing in AppleScript makes sense except in light of relational queries.

..

In fact, Apple events' query-based approach it actually makes a lot of sense considering it was originally designed to be efficient over very high-latency connections (i.e. System 7's abysmally inefficient process switcher), allowing a single Apple event carrying one or more complex queries to manipulate many objects at once. It's even quite elegant [when it works right], but is quite undone by idiots at Cupertino who think the best way to make programmers not hate the technology is to lie even harder about how it actually works.

So here, I suggest you go read this, which is not the best explanation either but still a damn sight better than anything you'll get from those muppets. And this, by its original designer that explains a lot of the rationale for creating a high-level coarse-grained query-based IPC system instead of the usual low-level fine-grained OO message passing crap.

Oh, and once you've done that, you might want to consider try running this instead:

delete every person whose name is "bob"

which is pretty much the whole point of creating a thick declarative-y abstraction that does all the work so the user doesn't have to.

And when nothing but an imperative client-side loop will do, you either want to get a list of by-ID object specifiers (which are the closest things to safe, persistent pointers that AEOMs can do) from the app first and then iterate over that, or at least use your own iterator loop that counts over elements in reverse:

repeat with i from (count every person) to 1 by -1
    tell person i
        ..
    end tell
end repeat 

so that, assuming it's iterating over an ordered array on the server side, will delete from last to first, and so avoid the embarrassing off-by-N errors of your original script.

HTH

foo
  • 3,171
  • 17
  • 18
  • Thanks for writing your long explanation, but I'm sorry to tell you that it can be made to work. See my own response I just wrote at the same time. At least it shows I'm not the only one having misunderstood this hole thing for a while :) – Thomas Tempelmann Apr 12 '16 at 14:10
  • You've rewritten your original question (which is bad form: it's far better to append new information instead), but what you've done in changing `repeat with p in every person` to `repeat with p in (get every person)` is _exactly_ what I said you could do as one way to get around the 'off by N' problem when your script absolutely needs to use a list: "get a list of by-ID object specifiers (which are the closest things to safe, persistent pointers that AEOMs can do) from the app first and then iterate over that". – foo Apr 13 '16 at 05:50
  • Regardless, it's still far better to use `delete every person …` in your script when possible (and for scriptable apps to support it), as it's _vastly_ quicker than getting a list of single element specifiers, iterating over it in AppleScript, and sending a separate `delete` event for each one, and [assuming a correctly implemented AEOM] eliminates the need for users to think or deal with crapwork like off-by-N issues in the first place. – foo Apr 13 '16 at 05:54
  • Ah, right. I learned about the "in (get every person)" later and forgot to mention that detail. I had to rewrite it because the original was giving much more detail that was not part of the problem. Updating would have added even more confusion to other readers, sorry for that. Still, without my code returning the UniqueID specifier, even that would not have worked as intended. I must admit that your long answer was a bit confusing to me, as I didn't see the actual answer in it. – Thomas Tempelmann Apr 13 '16 at 11:37
  • Also, using "delete every person whose" is not a helpful resolution here, because I was not asking about how to delete a set of items, I was asking about the solving the problem of the indexes not working when deleting (or modifying the array in any other way, e.g. a "move" command may have led to the same issue). – Thomas Tempelmann Apr 13 '16 at 11:37
  • Kudos for explaining the difference between "first-class query" vs "object pointer". I kept getting that confused, which was part of my issue as well. – Thomas Tempelmann Apr 13 '16 at 11:39
2

re: "If you want your scripable elements to be deletable, make sure you use NSUniqueIDSpecifiers to identify them."

Yes, Apple recommends using formUniqueId or formName for object specifiers, but you can't always do that. For instance, in the Text Suite, you really only have indexing to work with; e.g. character 1, word 3, paragraph 7, etc. You don't have unique IDs for text elements. In addition to deletion, ordering can be affected by other Standard Suite commands: open, close, duplicate, make, and move.

The app implementer is a programmer, but so is the scripter. So it is reasonable to expect the scripter to solve some problems themselves. For instance, if the app has 5 persons, and the scripter wants to delete persons 2 and 4, they can easily do so even with indexed deletion:

delete person 4
delete person 2

Deleting from the end of an ordered list forward solves the problem. AS also supports negative indexes, which can be used for the same purpose:

delete person -2
delete person -4
Ron Reuter
  • 1,287
  • 1
  • 8
  • 14
1

The key to solving this lies in implementing the objectSpecifier method correctly so that it does return an NSUniqueIDSpecifier.

My code did so far only return an index specifier and that was wrong for this purpose. I guess that had I posted my code (which is, unfortunately, too complex for that), someone may have noticed my mistake.

So, I guess the rule is: If you want your scripable elements to be deletable, make sure you use NSUniqueIDSpecifiers to identify them. For read-only element arrays, using an NSIndexSpecifier is (probably) safe, though, if your element array has persistent ordering behavior.

Update

As @foo points out, it's also important that the repeat command fetches the references to the items by using … in (get every person) and not just … in every person, because only the former leads to addressing the items by their id whereas the latter keeps indexing them as item N.

Thomas Tempelmann
  • 11,045
  • 8
  • 74
  • 149