Tab Completion

I'm Tab Atkins Jr, and I wear many hats. I work for Google on the Chrome browser as a Web Standards Hacker. I'm also a member of the CSS Working Group, and am either a member or contributor to several other working groups in the W3C. You can contact me here.
Listing of All Posts

Octonion Alchemy

Last updated:

A few months ago, I stumbled on an article about a physicist who believes octonion math can underly physics. It was a pretty interesting article in its own right, but what really grabbed me at the time was the diagram near the end, giving a brief explainer of complex, quaternion, and octonion math:

Tho I'd seen quaternion and octonion math before, I'd never quite seen the quaternion units depicted in that rock-paper-scissors diagram. And then when I looked down at the octonion diagram of the unit interactions, I was struck: that looks like a (simple) goetic diagram! The sort of "mystic circles and triangles with some latin bullshit" thing you see in anime alchemy and other magic.

I couldn't get this out of my head, and after some noodling on Twitter (link goes to the bottom of the thread to ensure you see the whole thing, scroll to the top to start reading) and chatting with my brother, I'd come up with a whole little overcomplicated elemental transmutation alchemy system which I really liked.

Here's the diagram in Google Diagrams. (I haven't spent the time to make it an SVG yet, sorry.)

lrn 2 alchemy nub

Reading the diagram requires a little explanation.

First, the circle, and each of the six straight lines (with the end looping back to the beginning to make a circle as well) describe seven transmutation cycles; the elements in each cycle can be transmuted into each other.

To do so, you take an element from one "node", the source, and an element from another node, the catalyst. This will produce one of the two elements in the third node of the cycle. Which one depends on the chirality of your alchemy: if your source and catalyst are both the "top" elements of their nodes, and tracing the diagram from source to catalyst follows the arrow connecting them, then the result is the top element of the final node. For example, fire catalyzed with water produces lightning; fire catalyzed with life loops around and produces aether; etc.

However, choosing the "bottom" element in a node inverts the result: fire catalyzed with air (inverted water) produces earth. Running the transmutation in reverse also inverts the result: water catalyzed with fire (inverted order) produces earth. Two inversions goes back to normal: ice catalyzed with air (both inverted, normal order) produces lightning; so does water catalyzed with ice (one inverted, inverted order). All three inversions (both elements from the "bottom", with the reaction run against the arrows) is back to inverting: air catalyzed with ice produces earth.

There's a hidden node in here, too, representing non-elemental substance. (In octonions, it's the value 1; the real unit, as opposed to the 7 complex units e₁-e₇.) By default it's mundane; a lack of magic. Inverted, it represents wild magic, destructive and reactive without the guiding template of an element constraining it. You can produce these by choosing the source and catalyst from the same node: if they're opposing elements, like fire and ice, they produce mundanity as the elements cancel out (regardless of source/catalyst order); if they're the same element, like fire and fire, they produce wild magic as the element burn themselves out from the clash but leave behind their energy.

Mundanity combined with anything produces the other thing: mundane catalyzed with fire produces fire, wild magic catalyzed with mundane produces wild magic, etc. Wild magic combined with anything inverts the other: fire + wild magic produces ice, wild magic + wild magic produces mundanity, etc.

(This, if it's not obvious, perfectly replicates octonion math, if you're only allowed to use the eight units, their negatives, and multiplication.)

And the point?

I imagine that in a video game using these, a character aligned with an element would do more damage with and take less damage from attacks of that element, but take more damage from attacks with the opposing element. Aligning with mundane gives you weaker attacks, but a generalized minor defense against all elements; aligning with wild magic gives you stronger attacks, but a generalized minor weakness against all elements. There'd probably be some environmental interactions, too, like using fire abilities on a fire-aligned hex would give some bonus, etc.

I could also imagine a game starting with just the central circle, representing the "classical" elements, and perhaps the druidic wood/metal center. You don't know about the triangle points, so you can't use the druidic elements in alchemy, except for using wild magic to invert it. Then, later in the game, you meet some people from a different magical tradition that introduce you to the "astral" elements (the three on the triangle points), and the rest of alchemy unfolds - you can finally use the druidic element in alchemy!

Other than that, eh, this is just a fun thing to think about. Thank you, complicated mathematical structure, for giving me just enough rules to wrap some interesting stories around.

I Understand Contravariant Functors Better Now!

Last updated:

Regular readers probably know that I enjoy some functional programming from time to time, and occasionally like to dive into Haskell and related stuff to teach myself something new. One thing that I've seen several times now, but not understood until now, is the idea of a "contravariant functor" or "cofunctor", and more generally the idea of something being covariant or contravariant over a type argument.

These concepts come from math, and I only vaguely understand what Wikipedia is talking about when they describe them. Translating over to category theory just obscures things further; it's made more difficult by the fact that both Abstract Math Wikipedia and Category Theory Wikipedia have fetishes for symbol-heavy statements that they apparently think make things clearer.

But at least within practical programming, I have a grasp on it, and I'll attempt to explain it to you, the abstract Reader.

Review of Covariance

So a quick review of "normal" functors. Say you've got a function that takes an integer and returns it "spelled out"; numSpell(1) returns "one", etc. You want to call this on some integers you've got lying around, but alas, they're stored in an array and you don't know how many there are. Not to worry, Array is a functor, so you can call its map() method, passing numSpell to it, and you'll get back an Array of strings, having had numSpell applied to each of the integers inside the array.

In the abstraction of types, what happened is that you started with an Array<Int> and a function Int->String, and the map() function let you combine these two to get an Array<String>. That's a normal, covariant functor.

Broken Intuition

Now, a contravariant functor, or cofunctor (I'm so sorry, category theorists are just the absolute worst at terminology) is kinda similar, but different in a way that's very confusing at first.

While map() takes an F<A> functor and a A->B function, producing an F<B> functor, contramap() takes an F<A> cofunctor and an B->A function, and produces an F<B> cofunctor. That's... backwards??? How can you take an B->A function, and a thing of type A, and get an B out of it?

Turns out the problem is in our intuition, or at least, in my intuition. I'd gotten very used to thinking in terms of the "functor is a container" abstraction. That serves you well for many functors, like Array or Option, but it can lead you astray for more abstract functors. More importantly, tho, it led me to develop an intuition that the type parameter of a generic like, like Array<Int>, was telling me what sort of value was inside the object; Array<Int> is an Array containing Ints, after all.

But that's not it at all! The type param is just telling me that, in the methods for the object, some functions will have different type signatures depending on what I specify. For Array<Int>, it's telling me that array access, the arr[i] syntax, will produce an Int, whereas in Array<String> the exact same syntax will produce a String.

In this case, and in general for functors, the type parameter is always going to be dictating the return value of some methods on the object. In other words, the functor is a producer of some sort of value, and the type param tells me what type of value that will be.

With this minor insight, the fact that map() can use an A->B function to turn an F<A> into an F<B> makes sense; the F<A> will try to produce an A value normally, then you pass it thru the A->B function and get a B instead.

Contravariance

So then we can take that minor insight further: what happens when the type parameter is instead telling you an argument type for methods? That is, the object consumes values of that type?

For example, say you have a Serializer class, that converts things to bytes, for writing to files. It can take lots of different types of objects, so it's qualified as well: Serializer<Int>, Serializer<Bool>, etc.

So first, this can still be a functor (or technically, functor-adjacent) - it produces bytes, so you can .map() over those bytes to produce something else, by passing a Bytes->Whatever function.

But the type term, the Int or Bool or whatever, doesn't show up in that function, as it just handles Bytes. If you have a Serializer<Int>, you can't pass it the numSpell function, which is Int->String, and suddenly get a Serializer<String>, aka something which knows how to consume strings and output bytes. numSpell just doesn't help with that; it turns Ints into Strings, while our Serializer<String> is given Strings, and the Serializer<Int> it was based on only knows how to serialize Ints.

In order to transform a Serializer<Int> into a Serializer<String>, we obviously need a String->Int function. Pay attention there: with normal functors, turning F<A> into F<B> requires an A->B function. But turning Serializer<A> into Serializer<B> requires a B->A function. This makes perfect sense in concrete terms: your starting functor produces As, so an A->B function lets you transform that into a B so you can start producing Bs instead; but your Serializer consumes As, so you need a B->A function so you can start accepting Bs instead, transforming them into the As you already know how to deal with. This makes Serialize a contravariant functor, or cofunctor, and so it has a .contraMap() method instead, which takes the opposite-direction callback and uses it to transform itself.

Profunctors, Etc.

So that's the essence here. If a functor is covariant in one of its types, that means it is, in some sense, producing values of that type; they're return values for some method. If you want to transform it from F<Int> to F<String>, you need to provide an Int->String method to .map(). If a functor is contravariant in one of its types, it's the opposite: it consumes values of that type, taking them as arguments to some method. To transform it from F<Int> to F<String>, you need to provide a String->Int method to .contraMap().

There's a two-typed structure called a Profunctor that does both at once; a Profunctor<Int, String> is contravariant in Int and covariant in String. The classic example of a Profunctor is... a function, like numSpell from earlier. In general, functions are characterized by two type parameters, A->B; for numSpell, that's Int->String.

If you want to change an A->B function to a C->D function, you need to provide two transformations, something to change the type A to C, and something to change the type B to D. Since the function produces a B, you want to give a B->D mapping function so it can start producing Ds instead; since it consumes an A, you want to give a C->A contramapping function so it can start consuming Cs instead.

Fast/Slow D&D Initiative System

Last updated:

D&D 5e's initiative system is more-or-less unchanged from much earlier editions. Every character has an "Initiative Bonus"; at the start of combat everyone (including all the DM-controlled enemies) rolls a d20 and adds their initiative bonus; then everyone takes their actions in descending order of their rolls. When everyone's gone once, it "goes back to the top" and repeats in the same order.

This... works. It gives you an ordering and lets you represent a faster character by giving them a higher initiative bonus... sorta. But it has several problems.

First, your initiative bonus just doesn't matter that much. A d20 has a lot of variation. Over the course of many rolls, you can distinguish between, say, a +2 and a +5 bonus in how many times you succeed. Initiative simply isn't rolled that often, tho, so a character with +5 to initiative won't feel like they're actually much faster than a character with +2 to initiative.

Second, in practice it's a rather slow, clunky way to start a battle. A perhaps dramatic build-up to combat suddenly screeches to a halt as the DM demand initiative rolls from everybody, rolls a bunch of initiatives for their monsters, and then sorts everything out. This can easily take several minutes! (It doesn't seem like it should - it sounds easy and quick - but theory and practice don't align well here. In practice, it's pretty slow.) Only after all that's done can combat, and fun, actually begin.

Third, once the initial initiative roll has happened, and the first round has finished, initiative... doesn't matter anymore. The order just determines who gets to strike first; after that, every round is the same for everyone: you go, then everyone else gets a turn, then you go, etc.

Fourth, while players aren't technically locked into their initiative result (they can delay and take their turn later if they need to), in practice players don't (for various practical reasons). This restricts what sort of combos people can use; it might be more effective to let the Fighter rush forward and have the Cleric hold back to see if they need to drop some heals or just do cleanup, but if initiative puts the Cleric first, generally they'll just go first. This can get very frustrating!

This article presents a better version of initiative, that both simplifies things and gives players more meaningful options. Their write-up didn't handle some corner cases well, tho, so I've reproduced and cleaned up the idea for my own purposes:

Fast/Slow Rounds

The core idea is that initiative is done away with. Instead, each round, players announce whether they'll be taking a "fast" or "slow" round. All the players taking a fast round take their actions immediately, in whatever order they decide amongst themselves.

Second, the DM decides which monsters are taking the "fast" or "slow" round - fast monsters take their turn now, in whatever order the DM wants. (Typically, all the "mooks" will go in the fast round.)

Third, all the players who chose to take a "slow" round take their turns, in whatever order they wish. However, because they held back, examining the battlefield and waiting for an opportune moment, they can add advantage or disadvantage to a single roll anyone makes during their turn. (They can give themselves advantage on an attack roll, or give an enemy disadvantage on a saving throw, or provoke an Opportunity Attack and give the enemy disadvantage on their attack, etc.)

Fourth, the "slow" monsters take their turn, and also get to impose advantage or disadvantage to one roll during their turn. (Typically, the "significant" enemies will go here.)

Then the round is over, and the next round begins, with players once again choosing to go fast or slow.

That's it! (Except for some of the additional quirks, noted later in this post.)

Benefits

In practice this ends up having a lot of benefits over traditional initiative.

  1. Because there's no big "initiative list" setup at the beginning of the combat, you can jump straight into combat with no delay. Just ask the players who's going fast, and you're off to the races. This has a surprising psychological effect on players, maintaining the drama that was built up pre-combat very effectively!
  2. Because the players can adjust when they take their action each round, they remain engaged thru more of the round, rather than just perking up on their turn and checking out a bit while they wait for everyone else to go. They plan out their actions along with the rest of the party, setting up combos and adjusting things for optimal safe ordering. You end up getting a lot more interesting teamwork out of people as a result!
  3. It's so fast! Even on a round-by-round basis, this really does make combat move faster. Because the players are working together and going all at once, their plans don't collapse as much due to enemies taking actions between them (and players don't simply forget what they were going to do, which is a significant danger normally...). As such, players don't have to reassess the battlefield before each of their turns - they know exactly what's changed, since it just happened and was part of the plan.
  4. No more (or at least, much less) forgetting about people! It's remarkably easy to occasionally skip people in the initiative when using it normally; if a non-active player asks a question, it's easy to slip back into the order as if they'd just gone. Since the players and enemies all go in just two large groups, tho, it's much simpler to track everyone - the players will remember themselves, and enemies become dramatically less fiddly to track.
  5. Slow rounds are amazing for players who want to get off a big dramatic action with less chance of whiffing. Similarly, they're great for making your Big Bad actually threatening, rather than several rounds of "They swing, and... they miss. Again. Your turn."

Fiddly Details

While the core rules above are trivial, there are a few additional details to cover.

First, several classes or feats give bonuses to initiative, which no longer do anything. (Alert feat gives +5, Revised Ranger gives advantage, etc.) While initiative bonuses aren't actually very significant, and thus it would probably be okay to just drop them, players don't like losing abilities even if they're minimal, and it's still a cool differentiator for a "fast" character.

As such, any ability that grants a "significant" initiative bonus (+2 or higher, more or less, but use your best judgement) is reinterpreted to let you get the slow-round bonus (adv or dis on one roll during your turn) during a fast round once per long rest. If you have multiple sources of bonuses, they stack to give you multiple uses of this ability.

The Bard's Jack of All Trades and the Champion's Remarkable Athlete don't count; their bonuses only range from +1 to +3 and aren't really "significant", plus most people don't realize they apply to Initiative in the first place (it's a Dex check!), so whatever.

There are some details to work out for spells that last X rounds (particularly those that are "one round") that I'm not sure about.

We Should Be Using Base 6 Instead

Last updated:

Occasionally you might come across someone who believes that it would be better for us to count in a base other than 10. Usually people recommend base-12 ("dozenal"); compsci people sometimes recommend base-2 (binary) or base-16 (hexadecimal). My personal opinion is that all of these have significant downsides, not worth trading out base-10 for, but that there is a substantially better base we should be using: base 6.

Let's explore why.

(Warning, this post is long and definitely not edited well enough. Strap in.)

Bases Are Arbitrary

First of all, there's nothing special about base-10. Powers of 10 look nice and round to us because we use base-10, but we can use any other base and get just as round numbers. Base 6 has 106, 1006, etc. (Those are 3610, 21610, etc; on the other hand, 1010 and 10010 are 146 and 2446. Converting between bases will usually produce awkward numbers no matter which base you start with.)

Why do we use base-10, then? The obvious answer is that we have 10 fingers. Counting off each finger gives us one "unit" of 10 things, and that unit-size carried over until we invented positional notation, where it froze into the base-10 we know today.

If we invented positional notation earlier, tho, then our hands could have supplied a better base - each hand can count off the values 0, 1, 2, 3, 4, and 5, which are exactly the digits of base-6. Two hands, then, lets you track two base-6 digits, counting up to 556, which is 3510!

Bases Are Significant

On the other hand, there are important qualities that do differ between bases.

The most obvious is the tradeoff of length vs mathematical complexity. Binary has trivial math - the addition and multiplication tables have only four entries each! - but it produces very long numbers - 10010 is 11001002, 7 digits long! On the other hand, using something like, say, base-60 would produce pretty short numbers - 1,000,00010 is only four digits long in base-60 ([4, 37, 46, 40]), but its multiplication table has 3600 entries in it.

When evaluating the tradeoffs of long representations vs complex mental math, it's important to understand a little bit about how the brain actually works for math. In particular, we have a certain level of inherent ability in various domains - short-term memory, computation, etc. Overshooting that ability level is bad - it makes us slower to do mental math, and might require us to drop down to tool usage instead (writing the problem out on paper). But undershooting it is just as bad - our brain can't arbitrarily reallocate "processor cycles" like a computer can, so when we undershoot we're just wasting some of our brain's ability (and, due to the tradeoffs, forcing something else to get harder).

So, we know from experience that binary is bad on these tradeoffs - base-2 arithmetic is drastically undershooting our arithmetic abilities, while binary length quickly exceeds our short-term memory. Similarly, we know that base-60 (used by the Babylonians, way back when) is bad - it drastically overshoots our arithmetic abilities while not significantly reducing the length of numbers, at least in the domain of values we care about (in other words, less than a thousand or so). So there's a happy medium somewhere in the middle here, and conveniently the geometric mean of 2 and 60 is base-11. Give it a healthy ±5 range, and we'll estimate that the "ideal" base is probably somewhere between base-6 and base-16.

But arithmetic complexity is actually more subtle than that. The addition tables, while technically scaling in size with the square of the base, scale in difficulty roughly linearly, since each row or column is just the digits in order, but starting from a different offset. It takes some memorization to recall how each offset works, but fundamentally the difficulty scales up slowly and simply, and you can do simple mental tricks to make addition easier anyway. (Such as adding 8+7, and adding/subtracting 2 from each to make it 10+5, a much simpler addition problem.)

Multiplication is more complex, tho. Some rows are easier to remember and use, others are more difficult:

  • "easy" rows are either trivial (0 and 1) or are factors of the base (2 and 5 for base-10), so they only cycle thru a subset of the digits in ascending order - less to memorize! Easy rows end up being pretty trivial to do mental math with; you can really easily multiply or divide in your head by these numbers.
  • "medium" rows are either smallish numbers that share all their factors with the base but aren't divisors (like 4 in base-10) because they also use only a subset of the digits but cycle thru them in a more complicated manner; or are the last row (9 in base-10) because of the nice pattern that makes its complexity easier to handle; or are just small numbers in general (like 3 in base-10), because even tho they cycle thru all the digits they do so in ascending series that are easier to memorize. Medium rows tend to be harder in mental math; you often need to resort to paper-and-pencil, but they're at least easy to do at that point.
  • "hard" rows are the rest - larger numbers that have some (or all) of their factors different from the base (6, 7, and 8 in base-10), so they cycle thru all the digits in a complicated manner, or just thru a subset in a complex manner + you have to track the 10s digit more carefully. Hard rows are just plain hard to compute with, even when you pull out paper-and-pencil. Rows that are coprime to the base, like 7 in base-10, are maximally difficult.

So multiplication difficulty varies in a complicated manner between bases, and doesn't scale monotonically. Base-60, for example, while looking tremendously bad for arithmetic at a naive glance, has significant mitigating factors here - because 60 factors into 2×2×3×5, a lot of the rows in the multiplication table are "easy", particularly among the more "useful" small numbers. (It also has a lot of maximally-hard numbers, of course - all the prime rows 7 or higher except for 59, and 49 - and even more merely "hard" rows.) We'll examine this in more detail in a bit.

Divisibility difficulty is very similar:

  • "easy" divisibility are the trivial values (1 and 10 in base-10), and values whose factors are a subset of the base's (2 and 5 in base-10, as 10 factors into 2×5). You only have to look at the final digit of a number to tell if it's evenly divisible by one of these values, and memorize which values correspond to divisibility and which don't. (0-2-4-6-8 for 2, 0-5 for 5.)
  • "medium" divisibility are the values who factor into the same primes as the base, but which use at most one more of a given prime than the base does. That is, since 10 factors into 2×5, 4 (2×2), 25 (5×5), 20 (2×2×5), and 50 (2×5×5) all have either two 2s or two 5s. These only require you to look at the last two digits of a number. (While 100 is 2×2×5×5, it's also just a power of the base, which clicks it over into "trivial" territory again.) Also "medium" is, again, the last value less than the base (9 in base-10) because you can always tell divisiblity by just adding up the digits and seeing if that value is divisible by your last row value. (Yes, this works in any base - you can tell if a hexadecimal value is divisible by F (15) by adding together the digits and seeing if the result is still divisible by F.) If this final row value is composite, any numbers whose factors are a subset are also medium, because the same trick applies: 9 is 3×3, so in base-10, you can indeed test for 3-divisibility by adding the digits and seeing if the result is divisible by 3.
  • "hard" divisibility is all the rest. The rows either exceed the base's factor usage by two or more (like 8 in base-10), and thus require looking at the last three or more digits, or they use a factor that's not in the base at all (like 6 in base-10) and so require you to look at the whole number in a more complicated way. And again, rows which are fully coprime to the base (like 7 in base-10) are maximally hard, with no easy tricks or bounded recognition possible; you just have to do the division and see if there's any remainder.

So What's Actually Best?

So, based purely on a naive length-vs-arithmetic-difficulty analysis, we've already concluded that the "ideal" base is likely between base-6 (heximal) and base-16 (hexadecimal). Now let's narrow that list down based on the more complex factors, above!

First off, we can cross off any odd base right off the bat. They lack easy mult/div by 2 (it becomes "medium" difficulty instead), which is a supremely important number to multiply and divide by. I don't think any other qualities could possibly make up for this loss even in theory, but as it turns out none of the odd numbers in that range are particularly useful anyway, so there's not even a question. They're gone.

So we're left with 6, 8, 10, 12, 14, and 16. Let's scratch off another easy one: 14 sucks. Its factors are 2 and 7, and 7 is the least useful small number. 14 has bad mult/div with all the other small numbers above 2. So it's gone too.

8 and 16 we can cover together, because they're both powers of 2. This makes them easy to use in computing, as you can just group binary digits together to form octal or hex digits, but it limits their usefulness in mental arithmetic - since 2 is their only factor, you don't get as many useful combinations of values to make mult/div easier. Plus, the "trick" that makes mult/div easier with the largest digit value is, in these cases, applying to 7 and 15, which are again not particularly useful values. So, while these have some mitigating factors with computing, they're not really contenders. Gone.

So we're down to 6, 10, and 12. I'll break these down more specifically, because they're all starting to get useful and we need more details.

Base-10 has 10 rows in its multiplication table. 0, 1, 2, and 5 are all "easy" - the patterns are trivial or at least very simple. 3, 4, and 9 are "medium" - the patterns are more complex, but not too hard to memorize and use intuitively. But 6, 7, and 8 are all "hard" - the patterns are hard to use, and the tens digit varies enough that it's an additional burden to memorization. (And 7 is "maximally hard".) So 40% easy, 30% medium, 30% hard.

Base-6 has 6 rows. 0, 1, 2, and 3 are all "easy", because 0 and 1 are trivial, and 2 and 3 divide 6 and thus are simple repeating patterns (2-4-0, 3-0). 4 and 5 are "medium"; 4 for the same reason as base-10, but moreso (pattern is just 4-2-0, or 4-12-20, a simple counting-down-by-evens pattern), and 5 is the last digit so has the same quality as 9 does in base-10 (5-4-3-2-1-0, or 5-14-23-32-41-50). It's got 66% easy, 33% medium, and no hard rows at all! On top of this, the whole times table is 1/3 the size, at only 36 entries vs 100; if you throw away the truly trivial x0 and x1 rows and columns, then it's a mere 1/4 the size, with 16 vs 64 entries! That's small enough to be simply memorizable regardless of patterns.

Now base-12, with 12 rows. 0, 1, 2, 3, 4, and 6 are all "easy", because 12 has lots of useful factors. 8, 9, and 11 are "medium", but 5, 7, and 10 are "hard" (and 5 and 7 are both "maximal"). This is a better distribution than base-10 (50% easy, 25% medium, 25% hard), but it's larger in general (12x12, so 144 entries vs 100) which makes it harder to memorize, so it's probably roughly equivalent to base-10 overall. That said, easy multiplication/division by 3, 4, and 6 is probably worth more in the real world than mult/div by 5, so I'm sympathetic to the claims of base-12 lovers.

And even tho I've already eliminated it, let's still examine base-16, which is real bad because its factors are less useful. 0, 1, 2, 4, and 8 are easy, 3, C, and F are medium, but 5, 6, 7, 9, A, B, D, and E are all hard, for a 31% easy, 19% medium, 50% hard distribution. That's not only substantially worse than base-10, it's also so much bigger (256 entries) that its overall difficulty is also higher. (And to make it worse, more than half of the hard rows (5, 7, 9, B, and D) are maximally hard, as they're coprime to 16! That's so much worse!) Base-16 is useful as a more convenient way to read/write binary, but it's horrible as an actual base to do arithmetic in.

Length of Numbers, and Digit "Breakpoints"

As mentioned earlier, binary is a bad base for humans, because it produces very long representations. Humans have a "difficulty floor" for dealing with individual digits, so having a long number full of very-simple digits doesn't actually trade off properly; you still end up paying a larger "complexity price" per digit, times a long representation, for a very complex result.

In base-10, numbers up to a hundred use 2 digits, and numbers up to a thousand use 3 digits. Base-6 is fairly close to this: 2 digits gets you to 36, 3 to 216, and 4 to 1296. Since we don't generally work with numbers larger than 1000 in base-10 (after that we switch to grouping into thousands/millions/etc, so we're still working with 3 or less digits), you get the same range from base-6 by using, at most, 4 digits. That's only gaining one digit; combine that with the vastly simpler mental math, and you're at worst hitting an equal complexity budget to base-10.

But there's more. You see, the 100/1000 breakpoints aren't chosen because they're particularly useful, they're just where base-10 happens to graduate up to the next digit. We use higher-level groupings rather than 10000 (in many languages, at least; traditional Chinese numbering groups by 10000) because 10000 is simply too large to usefully deal with. That is, we just can't think about 10000 things very well.

But we can't really think about things up to 1000 well, either. Even 100 is a pretty big chunk of stuff, larger than we traditionally like working with. Left to our own devices, we seem to like things maxing out at approximately 30-ish - that's how many days are in a month, and how many students are traditionally in a large class (at least in America...). Guess what's approximately 30-ish? That's right, the 2-digit breakpoint for base-6, 36!

The 3-digit breakpoint for base-6 is 216, which is also a pretty reasonable number. It's about twice as large as 100, so any time 100 would be reasonable, 216 is probably also reasonable.

So, altho you need four base-6 digits to reach 100010, I don't think that's particularly a useful goal to hit. 10006 being 21610 is sufficiently useful that it's worth still batching our numbers into 3-digit groups, like today. Benford's Law tells us that, even tho 216 is only 20% of 1000, it will generally cover a far higher percentage of numbers in actual usage; in order words, most of the time we'll write our number with three or less major digits anyway, and won't even miss the lost range!

As an added bonus, dividing things into groups of 3 is actually natural in base-6, unlike in base-10!

In Conclusion

So, base 6 has more useful divisors, making it easy to divide by many small numbers. It's got a smaller (and thus easier to memorize/use) addition table, and a multiplication table that's not only substantially smaller than base-10, but substantially easier in very significant ways, making mental arithmetic much simpler. We can cover a similar range of numbers with just three digits, so it even looks similar to base-10 when the numbers get large enough to need scientific notation.

If you ever find a time machine, let me know so I can fix this. ^_^

Strings Shouldn't Be Iterable By Default

Last updated:

Most programming languages I use, particularly those that are more "dynamic", have made the same, annoying mistake, which has a pretty high chance of causing bugs for very little benefit: they all make strings iterable by default.

By that I mean that you can use strings as the sequence value in a loop, like for(let x of someString){...}. This is a Mistake, for several reasons, and I don't think there's any excuse to perpetuate it in future languages, as even in the cases where you intend to loop over a string, this behavior is incorrect.

Strings are Rarely Collections

The first problem with string being iterable by default is that, in your program's semantics, strings are rarely actually collections. Something being a collection means that the important part of it is that it's a sequence of individual things, each of which is important to your program. An array of user data, for example, is semantically a collection of user data.

Your average string, however, is not a "collection of single characters" in your program's semantics. It's very rare for a program to actually want to interact with the individual characters of a string as significant entities; instead, it's almost always a singular item, like an integer or a normal object.

The consequence of this is that it's very easy to accidentally write buggy code that nonetheless runs, just incorrectly. For example, you might have a function that's intended to take a sequence as one of its arguments, which it'll loop over; if the user accidentally passes a single integer, the function will throw an error since integers aren't iterable, but if the user accidentally passes a single string, the function will successfully loop over the characters of the string, likely not doing what was expected.

For example, this commonly happens to me when initializing sets in Python. set() is supposed to take a sequence, which it'll consume and add the elements of to itself. If I need to initialize it with a single string, it's easy to accidentally type set("foo"), which then initializes the set to contain the strings "f" and "o", definitely not what I intended! Had I incorrectly initialized it with a number, like set(1), it immediately throws an informative error telling me that 1 isn't iterable, rather than just waiting for a later part of my program to work incorrectly because the set doesn't contain what I expect.

As a result, you often have to write code that defensively tests if an input is a string before looping over it. There's not even a useful affirmative test for looping appropriate-ness; testing isinstance(arg, collections.Sequence) returns True for strings! This is, in almost all cases, the only sequence type that requires this sort of special handling; every single other object that implements Sequence is almost always intended to be treated as a sequence.

There's No "Correct" Way to Iterate a String

Another big issue is that there are so many ways to divide up a string, any of which might be correct in a given situation. You might want to divide it up by codepoints (like Python), grapheme clusters (like Swift), UTF-16 code units (like JS in some circumstances), UTF-8 bytes (Python bytestrings, if encoded in UTF-8), or more. For each of these, you might want to have the string normalized into one of the Unicode Normalization Forms first, too.

None of these choices are broadly "correct". (Well, UTF-16 code units is almost always incorrect, but that's legacy JS for you.) Each has its benefits depending on your situation. None of them are appropriate to select as a "default" iteration method; the author of the code should really select the correct method for their particular usage. (Strings are actually super complicated! People should think about them more!)

Infinite Descent Shouldn't Be Thrown Around Casually

A further problem is that strings are the only built-in sequence type that is, by default, infinitely recursively iterable. By that I mean, strings are iterable, yielding individual characters. But these individual characters are actually still strings, just length-1 strings, which are still iterable, yielding themselves again.

This means that if you try to write code that processes a generic nested data structure by iterating over the values and recursing when it finds more iterable items (not uncommon when dealing with JSON), if you don't specially handle strings you'll infinite-loop on them (or blow your stack). Again, this isn't something you need to worry about for any other builtin sequence type, nor for virtually any custom sequence you write; strings are pretty singular in this regard.

(And an obvious "fix" for this is worse than the original problem: Common Lisp says that strings are composed of characters, a totally different type, which doesn't implement the same methods and has to be handled specially. It's really annoying.)

The Solution

The fix for all this is easy: just make strings non-iterable by default. Instead, give them several methods that return iterators over them, like .codepoints() or what-have-you. (Similar to .keys()/.values()/.items() on dicts in Python.)

This avoids whole classes of bugs, as described in the first and third sections. It also forces authors, in the rare cases they actually do want to loop over a string, to affirmatively decide on how they want to iterate it.

So, uh, if you're planning on making a new programming language, maybe consider this?