Rob Pike's simple C regex matcher in Go
benhoyt.com
benhoyt.com
https://github.com/redis/redis/blob/99ebbee2b260b3466672b4fa...
Demo: https://tio.run/##tVddc6IwFH3nV0Q7WyXFIlrdUWr7uC/9B213BkIQKo...
pattern = "a*b*c*d*e*f*g*h*i*j*k*l*m*n*o*p*q*r*s*t*u*v*w*x*y*z"
text = "aabbccddeeffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzza"
running time = 1.6s
It should be relatively easy to fix this. Insight: If the pattern contains two or more wildcards (i.e. it has the form "<A>*<B>*<C>"), and you've found a match for "<A>*<B>", but you can't find a match for "*<C>", you can stop immediately without considering longer matches for the first wildcard. The reason is that a longer match for the first wildcard will mean you to start looking for "*<C>" later in the string, and that's strictly harder.The repetition operator provokes the cliff because it represents short-hand syntax for easily increasing `m` to very large sizes. Indeed, by default, '.{12345}' will fail to compile because it blows the default limit. That only works because, as I said, the cliffs for finite automata based regex engines are far more predictable.
An NFA / DFA would be a perfectly valid way to implement an efficient matcher, but it's not the only way.
For example, memoization (https://news.ycombinator.com/item?id=32434412#32436442) can be easily shown to have time complexity O(P * T) where P = len(pattern) and T = len(text), which is the same as a basic NFA implementation.
In the case of the Redis code, my proposed fix requires much less code and memory than memoization or an NFA, and has time complexity O(P * T). It's slightly harder to prove this bound; here is a sketch of the proof:
Matching against a pattern fragment that does not contain "*" takes O(len(pattern_fragment)).
When a call to stringmatchlen() encounters a wildcard, it may recurse O(T) times (and then it returns). Each recursive call can be classified either "leaf" (if there is a match failure before the next wildcard) or "full" (if the match reaches the next wildcard).
The insight I described leads to at most one "full" call at each level of the recursion (i.e. for each wildcard in the pattern). So for a particular pattern fragment, the number of "leaf" calls that process it is O(T). Hence the overall time complexity is O(P * T).
Although its space complexity is P*T, which limits its use to small regexes/haystacks. It sounds like your claim is that your technique achieves a smaller space bound?
Since Xerox Research Europe's invention of xfst, the term "extended regular expression" is already taken for a specific superclass of expressions that describe regular relations.
So I propose two alternatives, semi-regular expressions and super-regular expressions, for strings that look like regular expressions but that use non-regular extensions like backreferences that push the complexity beyond what can be done with NDAs/DFAs.
It's certainly how it's used colloquially.
It seems sillier to say “that’s not a regex” (since it definitely is a regular expression engine), than to say that the others are extended regular expressions or some other, more flexible thing (for better and worse).
I am mostly talking about set intersection and complement. But https://en.wikipedia.org/wiki/Regular_language#Closure_prope... has some more.
(To be fair, the regex crate can’t match canonically equivalent Unicode strings, which I also think is pretty meh, but I’ve played with Unicode normalization recently and I’m convinced it would be impossible to support canonical equivalence with the same order of magnitude in performance without going through horrible contortions.)
[1] https://news.ycombinator.com/item?id=31693008
[2] https://news.ycombinator.com/item?id=17652542
Yeah not "meh" at all. :-) I don't think any regex engine can do it? Does icu's regex engine even do it? Certainly no fsm regex engine does.
UTS#18 doesn't even ask regex engines to do it. If you need it, it just suggests canonicalizing both the regex and the haystack first.
And yes, derivatives build full DFAs. Those are a no-go in a general purpose regex engine.
There's also the issue of complement and intersection being somewhat difficult to reason about.
I'll also note that I have never given any serious effort to actually trying to implement complement or intersection. I just don't see any viable implememtation path to it in the first place. I don't know where I would start. Everything I cam think of involves extreme compile-time costs that would just be totally unacceptable in a general purpose regex engine.
But, I am not really an "ideas" person. I see myself more as an engineer. So my inability should not really be taken for gospel. Be skeptical of expertise. That's the foundation of science. :)
Unless I can’t read, ICU4C does not even do streaming normalization, and the buffer-at-a-time one is around 100—200 MB/s or so[1], which looks ... mildly amusing next to your engine :) My own attempt at streaming normalization is currently about two times slower, because it has deeper (and smaller) tables and is just generally dumb, but then ICU4C is not particularly smart either. I expect that normalization at 2x ICU speed or more is possible, but that still won’t make normalize-then-match competitive with no normalization, while encoding all canonical equivalents into the DFA sounds downright hellish (also strictly speaking impossible, given normalization requires unbounded buffer space, but that part is probably solvable).
UTS #18 level 2 (which I’m aware you explicitly did not attempt) kind of says some things about canonical equivalence and then goes “nah, normalization is difficult, let’s go match grapheme clusters, also maybe just prenormalize to NFD”[2]. Which just looks weird, but apparently it means they did try to impose a requirement then gave up more than a decade ago[3]. Does anything even fully implement level 2, I wonder?
So, meh, but not really directed at you—regex is supernaturally fast as far as I’m concerned. Perhaps I’m annoyed at Unicode normalization for being (not really complicated but) so punishingly difficult to do quickly it isn’t really clear how you could wire it into a regex matcher.
There is also the question of how normalization-invariant regexes should even work: should “<C-caron>.” match “<C-cedilla><caron>”? UTS #18 (with invariance requirement restored) seems to imply yes, but it’s hard to imagine when anyone would actually want that. (Note that both pattern and haystack are normalized—the corresponding regex for NFD input would be something like “(C<caron>[[:ccc=0:][:ccc>=230:]]|C[[:ccc>0:]&[:ccc<230:]]<caron>)”, but nothing implements that syntax.) So while UTS #18 gives a possible interpretation of how Unicode regexes should work, I’m not entirely convinced it’s the right one, or even right enough to go on the crusade of implementing a normalization-invariant regex engine.
[1] Or so I thought, but my tests used UTF-32 while apparently UTF-16 is much faster: https://tzlaine.github.io/text/doc/html/boost_text__proposed.... Ugh.
[2] https://www.unicode.org/reports/tr18/#Canonical_Equivalents
[3] https://www.unicode.org/reports/tr18/tr18-13.html#Canonical_...
Not sure about backtrackers, but I know ICgrep attempted quite a bit of level 2: http://international-characters.com/icgrep
But I'm not sure how far they got. And most of the links on that page are dead. :-(
And yeah, UTS#18 actually used to have a level 3 (custom tailoring), but they removed it.
I'm content with level 1 support. The regex crate is just about there and that actually makes it have better Unicode support than the vast majority of regex engines. :-)
Level 2 is indeed hard.
Lazily compute derivatives? From memory Rust regex computes and caches a DFA lazily from the NFA, for comparison.
It doesn't strike me as something amenable to lazy compilation. But maybe I'm wrong.
So instead of inventing new words haphazardly, we should look at what kind of language we are able to express with these non-regular expressions, what the name of that language is and then use that name for the expressions.
Here is a starting point: where do regular languages sit in the Chomsky hierarchy:
https://en.wikipedia.org/wiki/Regular_language#Location_in_t...
Here, we see that there are also less inclusive languages included in regular: finite languages and star-free languages, for which the corresponding "finite expression" and "star-free expression" make sense.
In the other direction, maybe some useful language kinds can be identified between "regular" and "context-free" in relation to the capabilities of certain kinds of expressions. However, "context-free expression" isn't a bad start. ("Cfex" [si:fex]?)
I had an interview at google years ago where I basically I was asked to come up with this on my own. In my opinion, a terrible interview Q (per Kernighan's blog, linked in original post, it took pike an hour or two alone in his office), and I bombed it. It was either my just before or just after lunch interview and it stuck with me for the rest of the day and I couldn't give my entire focus to the rest of the sessions.
anyways, after it was done, I found kernighan's blog post and decided I should try to implement this in java as it will allow me to even get some practice with things like junit and more experience with object oriented programming as I had been living in the C world at that time). so I did, but I then found this regular expression page ( https://www.regular-expressions.info/) and learned many more things about "regular expressions" that i hadn't learned in school (because they aren't regular) and wondered if I could extend pike's simplicity to them in a relatively simple manner. So I did.
which I created this https://github.com/sjpotter/regex.
Not meant to be "performant" but meant to be educational to me and perhaps others.
Then when I was learning go, I rewrote it in Go. I did things that are not "normal" go patterns (i.e. using panic and recover to make error handling cleaner (in my opinion, if an error path is simply if (err) { return err } all the way up, I personally think panics/recover at the top can be cleaner, but it seems most disagree), but it was educational on how to rewrite my java code to an object oriented style in Go and to explore the limitations of interfaces and how one might get around them (also perhaps in ways that go against what might be considered proper go programming)
https://github.com/sjpotter/regex-go
though now that I look at that repo, wondering if all the work was done elsewhere and then I just pushed a single commit there, might need to go back and look at it
I refuse to believe anyone could imagine it's possible. Either that or there's a whole bunch of hidden talent wasting away at Google - which sounds unlikely.
(I did interview training at Google twice)
I still think it's a terrible interview format, though.
Works quite well because it can take advantage of the host of optimizations inside the regex engine.
If I remember correctly I was asked to be able to do a regex that handled . ? and *
The problem I have with this question is that if you spend 10-15 minutes going down a very wrong path, it's very very difficult to correct yourself in a pressurized environment.
I've tried to take the question and reformat it.
i.e.
I ask, what language do you want to program in (presuming I'm comfortable with the languages they want to use, otherwise I wouldn't be in the room, as wouldn't be a fair evaluation).
I give them the basic "strcmp" from pike's implementation (i.e. move character by character matching). i.e. something like
int match(char *s, char *r) {
if (*s == '\0' && *r == '\0') {
return 1;
} else if (*s == *r) {
return match(s+1, r+1);
} else {
return 0;
}
}
First Q. What does this do? (explained above)Second Q. how could we modify it work to match a substring (my explanation would be to just iterate over text like pike, but as with everything need to be open to where the interview takes you, but sometimes might need to say "that works for this, but for the future Qs, might want to think of it in this way", and give them some code, as again, I dont want them to code themselves into a corner that they don't have time to get out of).
Third Q. how could we add anchor support (ala regex, with a bit of explanation if they don't know what they are) (ala pike, determines where we are in the text, ala for ^ no iterating, and for $ we should be at a '\0' in the text
Fourth Q. how could we add "." support (just a || change to the primary matching condition, with being smart to check for '\0' which shouldn't match, but in a pressurized environment they might not get that without prodding right away)
Fifth Q. how could we add "?" support (now we have to look ahead a bit ala what pike does with )
Sixth Q (and the hardest), how could we add support.
I want to see if they can take some existing code, digest it and then see how they think to expand it. As there are many deliverables along the way, I'd hope I'd get a decent amount of "signal" even if they can't get to "?" or "*"
I also give them the link to kernighan's discussion on pike's code at the end.
if people have critiques of this approach to the Q (or further ways to improve it, I'm open)
Count me among those. At best, panic/recover would be an uglier version of throw/catch with even worse performance.
That said, I hope someday Go adds the "?" return-operator that kotlin and Rust use. Won't really change anything besides adding syntactic sugar for an already recommended pattern. But development ergonomics would improve a lot.
in all the production code I've written in go since then, I haven't used panic/recover because its not considered proper. But I'm still a bit skeptical as do think it can make code a bit cleaner when writing a library (I'd 100% agree though that the panic should never escape the library though and only a plain error returned to the caller, i.e. need strong library boundaries for this).
Same here. I think this is my biggest code-reading pain point as a go developer. I'm toying with the idea of playing more with Go+
https://github.com/goplus/gop/blob/main/doc/docs.md#error-ha...
And this also taught me an elegant way to implement recursion in C, that I shamelessly used in interviews to look way smarter than I actually am.
Here is a simple patch to make the runtime O(len(pattern) * len(text)) instead of exponential. It adds 5 lines and a new parameter. The idea is to memoize (cache) the results, specifically the failures (there is no need to cache the successes, because all the functions return immediately on success).
// Match reports whether regexp matches anywhere in text.
func Match(regexp, text string) bool {
+ seen := make(map[[2]int]bool)
if regexp != "" && regexp[0] == '^' {
- return matchHere(regexp[1:], text)
+ return matchHere(regexp[1:], text, seen)
}
for {
- if matchHere(regexp, text) {
+ if matchHere(regexp, text, seen) {
return true
}
if text == "" {
return false
}
text = text[1:]
}
}
// matchHere reports whether regexp matches at beginning of text.
-func matchHere(regexp, text string) bool {
+func matchHere(regexp, text string, seen map[[2]int]bool) bool {
switch {
case regexp == "":
return true
case regexp == "$":
return text == ""
case len(regexp) >= 2 && regexp[1] == '*':
- return matchStar(regexp[0], regexp[2:], text)
+ return matchStar(regexp[0], regexp[2:], text, seen)
case text != "" && (regexp[0] == '.' || regexp[0] == text[0]):
- return matchHere(regexp[1:], text[1:])
+ return matchHere(regexp[1:], text[1:], seen)
}
return false
}
// matchStar reports whether c*regexp matches at beginning of text.
-func matchStar(c byte, regexp, text string) bool {
+func matchStar(c byte, regexp, text string, seen map[[2]int]bool) bool {
for {
- if matchHere(regexp, text) {
+ if seen[[2]int{len(regexp), len(text)}] {
+ return false
+ }
+ if matchHere(regexp, text, seen) {
return true
}
if text == "" || (text[0] != c && c != '.') {
return false
}
+ seen[[2]int{len(regexp), len(text)}] = true
text = text[1:]
}
}
Demo: https://go.dev/play/p/aD9vzXwTHGEAnd with compilation https://github.com/rurban/tiny-regex-c (done)
Is there a concern with these kinds of micro-benchmarks, where you repeatedly do the same small operation (matching the same regex), that your results will be skewed by the branch predictor, CPU caches, etc.?
Btw always love your writing Ben, it’s very engaging.
https://trends.google.com/trends/explore?date=all&geo=US&q=R...
I think the go example is much more readable.
So the (for example) strcmp "while (s++ == d++);" made sense as efficient code, because the pointer access and the post increment effectively compiled down to almost a single instruction.
https://en.wikipedia.org/wiki/PDP-11_architecture#General_re...
I slept on it and the solution still isn't clear to me.
As an aside: It'd be nice if the go unit tests logged an indication of whether or not the ./matchC exec tests ran.
Good code looks deceptively simple.