Har du noen gang fått en melding som sier: “Regex er alt du trenger for å løse dette”? Du åpner filen, ser på det kryptiske mønsteret av tegn og får et akutt behov for en ekstra kopp kaffe. Regex kan være genialt – men vi har en liten bekjennelse: Noen ganger er det like greit å legge ned regex-bibelen, ta et par steg tilbake og tenke: Er det virkelig nødvendig?

I dette lille eksperimentet har vi testet hvor raskt det går å trekke ut GET fra strengen “bla.bla.GET”. For ordens skyld: Regex-løsningen er kjempeenkel – men samtidig fikk vi en påminnelse om at selv om regex funker, så kan det fort bli litt “dyrt” i drift.

Slik testet vi…

regexp.MustCompile()#

Regex er som en mektig trollmann, og stiller seg først i køen: MustCompile(), veiv litt med tryllestaven, og plutselig finner du det du leter etter. Men trollmenn driver gjerne også med litt mørk magi, og det kan koste deg ekstra krefter (CPU-tid).

func BenchmarkRegexpSplit(b *testing.B) {
    text := "bla.bla.GET"
    re := regexp.MustCompile(`\.(\w+)$`)
    for i := 0; i < b.N; i++ {
        res := re.FindStringSubmatch(text)
        if res[1] != "GET" {
            b.Fail()
        }
    }
}

Resultat: ~214 ns/op (nanosekunder per operasjon).

Her kan du nesten høre prosessoren stønne mens den maser seg gjennom understrenger og matcher mønstre: “Å, la meg bare teste litt til… jeg fant ‘GET’! Puh!”

strings.Split()#

Deretter var det den tradisjonelle og fortreffelige strings.Split sin tur, som, bokstavelig talt, splitter en streng på det du ber om (i dette tilfellet punktum). Den er ganske rask, men du betaler en liten pris for at den lager en slice med alle delene, og så henter du ut siste element:

func BenchmarkStringsSplit(b *testing.B) {
    text := "bla.bla.GET"
    for i := 0; i < b.N; i++ {
        parts := strings.Split(text, ".")
        res := parts[len(parts)-1]
        if res != "GET" {
            b.Fail()
        }
    }
}

Resultat: ~64 ns/op.

Manuell ByteSplit#

Til slutt har vi den kløktige (og litt old-school) løsningen: gå rett på byte-nivå. Ingen funksjon som heter “split” eller “compile”, bare en enkel løkke. Da leter du etter siste punktum og returnerer alt som kommer etter:

func ByteSplit(s string) string {
    var lastDot int
    for i := 0; i < len(s); i++ {
        if s[i] == '.' {
            lastDot = i
        }
    }
    return s[lastDot+1:]
}

Resultat: ~7 ns/op.

Ja, du leste riktig: sju nanosekunder. Selv en gjerrig bestemor med stoppeklokke vil bli imponert.

Hæ, er regex virkelig så tregt?#

Regex er en generisk løsning som lar deg matche alt fra e-postadresser til sonetter på blankvers. Men maskineriet bak er ikke gratis. Det er litt som en sveitsisk lommekniv med 75 innebygde funksjoner – den fikser alt, men for et lite kutt i en løk holder det ofte med en vanlig kjøkkenkniv.

Manuell splitting eller strings.Split er spesialiserte verktøy. De gjør få ting, men de gjør dem fort og effektivt.

Historiens moral#

Datamaskiner er kjemperaske, men det skader ikke å la dem slippe å rulle ut den tunge regex-kanonen for hver lille ting.


Jeg vil teste dette på min maskin!#

mkdir splittest && cd splittest
go mod init splittest
go mod tidy
# filename: main_test.go
package main

import (
	"regexp"
	"strings"
	"testing"
)
// res[0] = '.', res[1] = "GET"
func BenchmarkRegexpSplit(b *testing.B) {
	text := "bla.bla.GET"
	re := regexp.MustCompile(`\.(\w+)$`)
	for i := 0; i < b.N; i++ {
		res := re.FindStringSubmatch(text)
		if res[1] != "GET" {
			b.Fail()
		}
	}
}

// Split such that we have strings := []string{"bla", "bla", "GET"}
// and fetch "GET" i.e., strings[len(strings)-1]
func BenchmarkStringsSplit(b *testing.B) {
	text := "bla.bla.GET"
	for i := 0; i < b.N; i++ {
		parts := strings.Split(text, ".")
		res := parts[len(parts)-1]
		if res != "GET" {
			b.Fail()
		}
	}
}

// iterate character by character, find a dot, it's the new 'lastDot' 
// until we reach final dot, we get the index and return lastDot+1 
// until end of string
func ByteSplit(s string) string {
	var lastDot int
	for i := 0; i < len(s); i++ {
		if s[i] == '.' {
			lastDot = i
		}
	}
	return s[lastDot+1:]
}
// Use function above to retrieve "GET"
func BenchmarkByteSplit(b *testing.B) {
	text := "bla.bla.GET"
	for i := 0; i < b.N; i++ {
		res := ByteSplit(text)
		if res != "GET" {
			b.Fail()
		}

	}
}
❯ go test -bench=. -benchtime=5s