The Perils of Building a Text Editor In Swift

They say the best way to learn is by experience, but sometimes experience is a pretty lousy teacher. It rarely gives you the information you need when you're ready for it, and it hardly cares whether you'll succeed or not. As far as I'm concerned, experience can suck it. That could hardly be more true than when dealing with programming languages.

I'm a Swift programmer and I love it. Swift code is beautiful and clean. The moments when I feel most like an artist are when I'm designing a new algorithm in Swift. It graciously removes nearly all of the ambiguity and mental overhead that comes with traditional C-style languages (good-bye pointers) allowing me to think less about it and more about the way it works.

But there's a problem.

Apple have put a tremendous effort into ensuring that their iOS frameworks play nicely with Swift, for which I am very grateful, but 20+ years of C and Objective-C don't just magically transform over night. Despite all those efforts, Swift is still a second class citizen. I've had to learn this the hard way as I refactor Ibsen Writer and prepare it for release this fall. Ibsen is a text editor (NSTextStorage ahoy!) which relies heavily on Apple's own frameworks to work it's textual magic, and it's in those frameworks where the Objective-C to Swift transition hurts the most and demands the most of our attention.

I don't believe in letting experience teach you everything, so I'm going to teach you something experience won't. If you're building a text editor in Swift, these are the things you need to know – the non-obvious pitfalls that will bring your code to its knees if you aren't careful. These are the things I had to learn by experience because nobody else could teach me. 

You're welcome.

String vs NSString

Before we can even begin to talk about Swift, we have to talk about Unicode. Whether you already think you know what it is, or you already know that you don't, it's worth going to the trouble of reiterating its importance. Unicode is a standard. More to the point, it's a standard that the whole of the computing world has adopted. If I create some text in Uganda, I can reasonably expect that it will look exactly the same in New York, New Zealand, and New London (whenever that happens). But there's a catch. Really, there are three different catches (UTF-8, UTF-16, and UTF-32, the three horsemen of the Unicode apocalypse) and jumping between them can be a pain.

UTF-8 is the nicest of these. It has a small encoding size making it super space efficient, and it has a long heritage making it compatible with just about everything in the known universe. It's what Perl uses. It's what the web uses. It's what plain text files are saved in. Unfortunately, neither Swift nor Objective-C use UTF-8.

Strings in Swift are UTF-32 encoded, the newest of the three Unicode flavors. Actually, it's a little more accurate to say that Strings are UTF-21 encoded, but that's not really important for us to understand. The short of it is that Swift Strings are and will remain fully Unicode compliant for years to come. That's one of the key selling points of the Swift Standard Library and Swift as a whole: you can throw just about any string of text at it and you'll never have to worry about how that string will be interpreted. That's the promise of Unicode and it's what Swift delivers.

But NSString, the Objective-C equivalent, is encoded in UTF-16 – an uncomfortable compromise between its two siblings – which introduces more than a couple problems for anyone wishing to move between NSString and String. Sure, Swift can read UTF-16 just fine – Unicode is Unicode – but moving between the two is like deflating and reinflating an air mattress: you just can't expect that kind of conversion to be any kind of efficient. Possible, yes, but O(1)? Not a chance. At best, you're looking at an O(n) operation which for large strings (the kind you might find in a text editor) can quickly lead to your program slowing down and begging for the sweet release of death*.

Casting NSString to and from String doesn't solve this problem. It is the problem. Any time you can avoid it, do. 

Subclassing NSTextStorage

So why not just avoid using NSString altogether and do everything the Swift way? Didn't I say earlier that Swift strings are superior to begin with?

They mostly are, but there's a problem: if your text editor is doing anything non-trivial with that text, you're going to have to subclass NSTextStorage, and that means using NSString in the background, whether you like it or not.

You can't change the fact that NSTextStorage uses NSString. It's baked into UIKit. Whenever you call textStorage.string from anywhere in your Swift code, you can be sure that your program is now silently converting an NSString into a String at roughly O(n) or worse without your consent. To see the kind of effect this can have on a program, try opening a large text file (~ 50,000 words should do the trick) with a custom Swift subclass of NSTextStorage and use the Time Profiler in Instruments to see how long that operation can take.

And then weep.

Apple is already aware of this issue and possibly already has plans for fixing this in the future (my tea leaves certainly say so), but for now there is really only one solution: don't use Swift to subclass NSTextStorage. At least when it comes to text, you're still better off using Objective-C.

Ouch. Typing that sentence actually hurt.

Ain't No Such Thing As A Toll-Free Bridge

While we're hanging out in the Time Profiler, let's go ahead and see how well Swift deals with casting NSDictionary as a Swift Dictionary.

Turns out, not much better.

It seems there isn't such thing as a true toll-free bridge. At least, not between the Swift Standard Library and Foundation. The toll for language compatibility is speed, and if you want to avoid the pitfalls of O(n) operations that means using the language your fundamental types were originally written in.

Don't let UIKit cast NSDictionary as a Dictionary, either. If you have code that adds attributes to an NSAttributedString (which, if you're subclassing NSTextStorage, you do), that code should also be written in Objective-C. I know, it pains me to say it, but NSAttributedString uses NSDictionary to store attributes for each character and there's nothing you can do to change that. Work with the system and use Objective-C, or else the costs of converting between Dictionary types will bite you the same way converting String types will.

Fix Attributes Lazily

This is getting depressing. How about some good news?

Depending on the nature of your text editor, you may be able to get away with a clever trick that will improve your performance significantly just by adding a few lines of code to your NSTextStorage subclass. Careful, though. What I'm about to suggest is not for everyone, and it should only be approached with extreme caution.

Set the NSTextStorage variable fixesAttributesLazily to return true.

By default, NSTextStorage tries to sanitize each NSDictionary in its backing NSMutableAttributedString store whenever a change is made. Attribute fixing is non-negotiable if your program allows direct user manipulation of attributes. Microsoft Word couldn't get away with this change. Not a chance. It's too risky. But a Markdown editor like Editorial, which algorithmically generates its own attributes, that probably could.

The key is to generate everything yourself (font, paragraph style, color, etc) and ensure the generated attributes you code for won't conflict. By setting fixesAttributesLazily to true and not calling ensureAttributesAreFixedAtRange: like your supposed to, you remove an important but slow safety feature. Doing so isn't particularly Swifty and safe, and doing so without adding all of the requisite attributes by hand will result in your text failing to render, but if you have a program that can get by without it and you're careful with your code, it's not as if it'd be doing anything other than slowing your program down, anyway.

Just, be careful. 

You Must Cache Everything

My next point isn't unique to Swift, but it is unique to text programming. Remember how I said that O(n) algorithms are anathema to text? How they'll leave your programs begging for mercy and praying to every god in the known universe?

Yeah. They're that bad. Any time you start thinking about scanning the whole document to compute something, don't.

Instead, take a page from the book of dynamic programming and start caching everything – especially important data like text attributes, word count, and syntax trees. NSTextStorage makes use of this idea already with its use of a private NSMutableAttributedString as its backing store, but you shouldn't be content to stop there. Get creative with how far this goes. Instead of parsing the whole file, only parse a single paragraph at a time. Instead of counting all paragraphs at once, count only the one's you don't already know about. And then, store them in a cache.

The sad truth is that O(n) algorithms are unavoidable when working with text. There isn't a parser in the world that can be anything less. But that doesn't mean that we are completely at their mercy. As programmers, we can be smart about how we use them. The name of the game is memoization, so cache, cache, cache!

Everything's Faster In C

My last point is definitely the least. It's hardly unique to Swift, and it certainly isn't unique to text programming. If I were a big boy I'd probably just suck it up and deal with it in my own code. But hey, this is my personal blog so I'll indulge myself in a little whinging to talk about something I hate.

Everything fast is written in C.

Okay, that's not 100% true. Not everything in C is fast. It's like arguing that men are always stronger then women. Sure, the strongest man will probably always be stronger than the strongest woman, and the fastest algorithms will probably always be written with punch cards in hexadecimal. But that's not really the point, is it?

If you're planning on incorporating external libraries into your text editor, by virtue of the strongest-man problem they will almost always be written in C – nasty, icky, low level C – and if not C then some wrapper that attempts to mask the smell. The problem isn't with C, per se, so much as how different it is from Swift. C uses char arrays and a janky enum system that falls apart just if you look at it funny, while Swift gives you a hammer to nail every value type you could ever imagine. C throws caution to the wind by asking you to allocate and release every block of memory while Swift tries everything in it's power to keep you from remembering that garbage collection is a thing you have to do or else your computer will explode. It's a fundamental difference in the way we think of computing. Using the two together is like being asked to bake a cake, only with a secret ingredient of sardines, and having to come up with some ingenious way of masking that fact that you're making a fish cake. It's not for nothing that we call our C-wrappers syntactic sugar. 

Using the two together is going to require some overhead, and it's not going to be pleasant.

Us programmers have a modern day philosopher's stone. We call it modularity – the idea that each library can be added without having to change a single line of source code or without having to refactor our own code to even begin to make sense of it. Like our elder alchemists, we're already convinced that we know how to make one and we're just missing the last spoonful of sugar that will make that whole process work. And by the flasks of Flamel, we've certainly tried. But nothing is so perfect. No library is so perfectly suited to every use-case that it can be dropped into place without either changing its original source code or yours.

And if that original source code is in C, then heaven help you.

 

  1. Speaking of death, accessing the nth index of a Swift String is also O(n). It would seem that if you really don't want to have to deal with the myriad complexities of text, the best route is to crawl under a rock and cry.