基于ParserCombinator的正則表達(dá)式引擎

這篇文章介紹如何用Parser Combinator實(shí)現(xiàn)一個(gè)簡(jiǎn)單的正則表達(dá)式引擎逢捺。

Cursor

Cursor封裝了字符串的狀態(tài),表示一個(gè)光標(biāo)位置癞季。光標(biāo)位置只能向后移動(dòng)劫瞳,可以方便地獲取光標(biāo)指向的字符倘潜,以及判斷是否到達(dá)字符串末尾。

public class Cursor {
    private final String input;
    private final int index;

    public Cursor(String input, int index) {
        this.input = input;
        this.index = index;
    }

    /**
     * 是否到達(dá)字符串結(jié)尾
     */
    public boolean end() {
        return index == input.length();
    }

    /**
     * 當(dāng)前指向的字符
     */
    public char current() {
        return input.charAt(index);
    }

    /**
     * 光標(biāo)向后移動(dòng)一個(gè)字符
     */
    public Cursor next() {
        return new Cursor(input, index + 1);
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Cursor cursor = (Cursor) o;
        return index == cursor.index && Objects.equals(input, cursor.input);
    }

    @Override
    public int hashCode() {
        return Objects.hash(input, index);
    }
    
    /**
     * 方便輸出顯示
     */
    @Override
    public String toString() {
        return String.format("Cursor{parsed: '%s', remain: '%s'}",
                input.substring(0, index), input.substring(index));
    }
}

測(cè)試代碼:

Cursor cursor = new Cursor("hello", 0);
while (!cursor.end()) {
    System.out.println(cursor);
    System.out.println("current: " + cursor.current());
    cursor = cursor.next();
}

輸出結(jié)果:

Cursor{parsed: '', remain: 'hello'}
current: h
Cursor{parsed: 'h', remain: 'ello'}
current: e
Cursor{parsed: 'he', remain: 'llo'}
current: l
Cursor{parsed: 'hel', remain: 'lo'}
current: l
Cursor{parsed: 'hell', remain: 'o'}
current: o

Cursor被設(shè)計(jì)成不可變的志于,主要是為了簡(jiǎn)化Parser的實(shí)現(xiàn)涮因。如果Cursor是可變的,則在解析的過(guò)程中需要時(shí)刻注意保存當(dāng)前光標(biāo)的位置伺绽,這樣十分麻煩养泡。

Cursorequals方法和hashCode方法用于判斷重復(fù)的Cursor狀態(tài),因?yàn)榻酉聛?lái)我們要把Cursor放進(jìn)集合里奈应。

Regex

Regex封裝了對(duì)字符串的解析操作澜掩,它從一個(gè)光標(biāo)位置開(kāi)始解析字符串,返回解析后所有可能的光標(biāo)位置杖挣。

public interface Regex {
    Set<Cursor> parse(Cursor input);

    default boolean match(String input) {
        return parse(new Cursor(input, 0)).stream().anyMatch(Cursor::end);
    }
}

parse方法就是解析操作的具體實(shí)現(xiàn)肩榕,它從一個(gè)光標(biāo)開(kāi)始嘗試向后解析,返回解析之后所有可能的光標(biāo)位置程梦。使用Set作為返回值類(lèi)型点把,可以對(duì)匹配結(jié)果進(jìn)行去重,避免重復(fù)解析相同的狀態(tài)屿附。

match方法是對(duì)parse方法的簡(jiǎn)單封裝郎逃,它用來(lái)判斷當(dāng)前Regex是否能消耗掉整個(gè)字符串。只要在解析結(jié)果中存在一個(gè)到達(dá)字符串末尾的光標(biāo)位置挺份,就說(shuō)明當(dāng)前解析器能把字符串消耗完褒翰。

接下來(lái),我們會(huì)實(shí)現(xiàn)一些基本的Regex實(shí)現(xiàn)類(lèi)匀泊,使用它們可以將任意正則表達(dá)式轉(zhuǎn)換成一個(gè)Regex的實(shí)例优训。調(diào)用Regexmatch方法,并傳入待匹配的字符串各聘,就能判斷這個(gè)字符串是否與正則表達(dá)式匹配揣非。

原子Regex

下面實(shí)現(xiàn)兩個(gè)基本的Regex,雖然它們只能實(shí)現(xiàn)簡(jiǎn)單的功能躲因,但是后面將非常有用早敬。

首先是匹配單個(gè)指定字符的解析器:

public class Ch implements Regex {
    private final char c;

    public Ch(char c) {
        this.c = c;
    }

    @Override
    public Set<Cursor> parse(Cursor input) {
        if (input.end() || input.current() != c) {
            return Collections.emptySet();
        }
        return Set.of(input.next());
    }
}

如果當(dāng)前光標(biāo)沒(méi)有到達(dá)字符串結(jié)尾,且指向的字符等于給定字符大脉,它會(huì)消耗掉這個(gè)字符搞监,然后返回剩余的部分,否則返回一個(gè)空列表作為解析失敗的結(jié)果镰矿。

測(cè)試代碼:

Regex r = new Ch('a');
System.out.println(r.parse(new Cursor("abc", 0)));
System.out.println(r.parse(new Cursor("xyz", 0)));
System.out.println(r.parse(new Cursor("", 0)));

輸出結(jié)果:

[Cursor{parsed: 'a', remain: 'bc'}]
[]
[]

與之類(lèi)似的琐驴,還有匹配任意單個(gè)字符的解析器,對(duì)應(yīng)于正則表達(dá)式中的元字符.

public class Any implements Regex {
    @Override
    public Set<Cursor> parse(Cursor input) {
        if (input.end()) {
            return Collections.emptySet();
        }
        return Set.of(input.next());
    }
}

測(cè)試代碼:

Regex r = new Any();
System.out.println(r.parse(new Cursor("abc", 0)));
System.out.println(r.parse(new Cursor("xyz", 0)));
System.out.println(r.parse(new Cursor("", 0)));

輸出結(jié)果:

[Cursor{parsed: 'a', remain: 'bc'}]
[Cursor{parsed: 'x', remain: 'yz'}]
[]

我們現(xiàn)在已經(jīng)有了能夠匹配單個(gè)字符的Regex,但是它們并不能做很多事情绝淡,最多只能讓光標(biāo)向前移動(dòng)一個(gè)字符宙刘。我們急需一種能夠?qū)⒍鄠€(gè)小的Regex組合成一個(gè)復(fù)雜的Regex的機(jī)制。

Regex的組合

接下來(lái)這幾個(gè)Regex非常重要牢酵,它們可以將一個(gè)或多個(gè)Regex包裝成一個(gè)功能更強(qiáng)大的Regex荐类。

假設(shè)我們有一個(gè)Ch('a')和一個(gè)Ch('b'),如何用它們組合成一個(gè)能夠匹配字符串abRegex呢茁帽?請(qǐng)看下面Concat的實(shí)現(xiàn),它表示對(duì)輸入串依次應(yīng)用lhsrhs這兩個(gè)Regex屈嗤,并返回所有能夠得到的結(jié)果潘拨,對(duì)應(yīng)于正則表達(dá)式中的連接操作。

public class Concat implements Regex {
    private final Regex lhs, rhs;

    public Concat(Regex lhs, Regex rhs) {
        this.lhs = lhs;
        this.rhs = rhs;
    }

    @Override
    public Set<Cursor> parse(Cursor input) {
        Set<Cursor> r = new HashSet<>();
        for (Cursor c : lhs.parse(input)) {
            r.addAll(rhs.parse(c));
        }
        return r;
    }
}

測(cè)試代碼:

Regex r = new Concat(new Ch('a'), new Ch('b'));
System.out.println(r.parse(new Cursor("abc", 0)));
System.out.println(r.parse(new Cursor("xyz", 0)));
System.out.println(r.parse(new Cursor("ac", 0)));

輸出結(jié)果:

[Cursor{parsed: 'ab', remain: 'c'}]
[]
[]

只要lhsrhs任意一個(gè)解析器解析失敗饶号,都會(huì)導(dǎo)致返回結(jié)果為空铁追。

注意,lhsrhs可能返回多個(gè)解析結(jié)果茫船,因此在實(shí)現(xiàn)的過(guò)程中需要遍歷所有可能的結(jié)果琅束。

返回值類(lèi)型為Set確保了結(jié)果中不會(huì)存在重復(fù)的狀態(tài)。

多個(gè)Concat可以串聯(lián)使用算谈,例如涩禀,以下Regex匹配以abc開(kāi)頭的字符串:

Regex r = new Concat(new Ch('a'), new Concat(new Ch('b'), new Ch('c')));

與此對(duì)應(yīng)的還有Or,它表示從lhsrhs中選擇一個(gè)執(zhí)行然眼,對(duì)應(yīng)于正則表達(dá)式中的|運(yùn)算符:

public class Or implements Regex {
    private final Regex lhs, rhs;

    public Or(Regex lhs, Regex rhs) {
        this.lhs = lhs;
        this.rhs = rhs;
    }

    @Override
    public Set<Cursor> parse(Cursor input) {
        Set<Cursor> result = new HashSet<>(lhs.parse(input));
        result.addAll(rhs.parse(input));
        return result;
    }
}

測(cè)試代碼:

Regex r = new Or(new Ch('a'), new Ch('b'));
System.out.println(r.parse(new Cursor("apple", 0)));
System.out.println(r.parse(new Cursor("banana", 0)));
System.out.println(r.parse(new Cursor("cat", 0)));

輸出結(jié)果:

[Cursor{parsed: 'a', remain: 'pple'}]
[Cursor{parsed: 'b', remain: 'anana'}]
[]

接下來(lái)的ZeroOrMore是一個(gè)很重要的Regex艾船,它實(shí)現(xiàn)了正則表達(dá)式中*運(yùn)算符的功能。它嘗試對(duì)當(dāng)前光標(biāo)多次應(yīng)用parser高每,每次應(yīng)用都會(huì)產(chǎn)生一個(gè)不同的光標(biāo)位置屿岂。在實(shí)現(xiàn)的過(guò)程中,使用Queue來(lái)進(jìn)行廣度優(yōu)先搜索鲸匿,同時(shí)用一個(gè)額外的Set來(lái)避免重復(fù)搜索爷怀。

public class ZeroOrMore implements Regex {
    private final Regex parser;

    public ZeroOrMore(Regex parser) {
        this.parser = parser;
    }

    @Override
    public Set<Cursor> parse(Cursor input) {
        Set<Cursor> result = new HashSet<>();
        Queue<Cursor> queue = new LinkedList<>(List.of(input));
        Set<Cursor> set = new HashSet<>(Set.of(input));

        while (!queue.isEmpty()) {
            int cnt = queue.size();
            while (cnt-- > 0) {
                Cursor cursor = queue.remove();
                result.add(cursor);
                for (Cursor c : parser.parse(cursor)) {
                    if (!set.contains(c)) {
                        queue.add(c);
                        set.add(c);
                    }
                }
            }
        }

        return result;
    }
}

測(cè)試代碼:

Regex r = new ZeroOrMore(new Ch('a'));
System.out.println(r.parse(new Cursor("aaa", 0)));

輸出結(jié)果:

[
    Cursor{parsed: '', remain: 'aaa'},
    Cursor{parsed: 'a', remain: 'aa'},
    Cursor{parsed: 'aa', remain: 'a'},
    Cursor{parsed: 'aaa', remain: ''}
]

與之類(lèi)似的,還有OneOrMore带欢,對(duì)應(yīng)于正則表達(dá)式中的+運(yùn)算符运授。它的實(shí)現(xiàn)與ZeroOrMore及其類(lèi)似,只是搜索的起始條件不同洪囤。

public class OneOrMore implements Regex {
    private final Regex parser;

    public OneOrMore(Regex parser) {
        this.parser = parser;
    }

    @Override
    public Set<Cursor> parse(Cursor input) {
        Set<Cursor> result = new HashSet<>();
        Set<Cursor> start = parser.parse(input);
        Queue<Cursor> queue = new LinkedList<>(start);
        Set<Cursor> set = new HashSet<>(start);

        while (!queue.isEmpty()) {
            int cnt = queue.size();
            while (cnt-- > 0) {
                Cursor cursor = queue.remove();
                result.add(cursor);
                for (Cursor c : parser.parse(cursor)) {
                    if (!set.contains(c)) {
                        queue.add(c);
                        set.add(c);
                    }
                }
            }
        }

        return result;
    }
}

測(cè)試代碼:

Regex r = new OneOrMore(new Ch('a'));
System.out.println(r.parse(new Cursor("aaa", 0)));

輸出結(jié)果:

[
    Cursor{parsed: 'a', remain: 'aa'}, 
    Cursor{parsed: 'aa', remain: 'a'},
    Cursor{parsed: 'aaa', remain: ''} 
]

構(gòu)造復(fù)雜的解析器

有了以上的原子Regex和各種組合手段徒坡,就可以構(gòu)造出任意復(fù)雜的Regex了,以下是正則表達(dá)式((a|b)c*)+對(duì)應(yīng)的Regex

Regex r = new OneOrMore(new Concat(new Or(new Ch('a'), new Ch('b')), new ZeroOrMore(new Ch('c'))));

可以看到瘤缩,這樣的寫(xiě)法嵌套很深喇完,用起來(lái)不方便,因此我們?cè)?code>Regex接口中添加一些靜態(tài)方法和默認(rèn)方法:

public interface Regex {
    ...
    static Regex any() {
        return new Any();
    }

    static Regex ch(char c) {
        return new Ch(c);
    }

    default Regex concat(Regex rhs) {
        return new Concat(this, rhs);
    }

    default Regex or(Regex rhs) {
        return new Or(this, rhs);
    }

    default Regex zeroOrMore() {
        return new ZeroOrMore(this);
    }

    default Regex oneOrMore() {
        return new OneOrMore(this);
    }
}

然后就可以通過(guò)鏈?zhǔn)秸{(diào)用構(gòu)造復(fù)雜的Regex了:

import static xxx.Regex;

// ((a|b)c*)+
Regex r = ch('a').or(ch('b')).concat(ch('c').zeroOrMore()).oneOrMore();

測(cè)試代碼:

System.out.println(r.match("acccbcccc"));
System.out.println(r.match("abcc"));

輸出結(jié)果:

true
false

從字符串生成Regex

到這里剥啤,其實(shí)我們已經(jīng)實(shí)現(xiàn)了一個(gè)簡(jiǎn)易的正則表達(dá)式執(zhí)行引擎锦溪,支持正則表達(dá)式中常用的操作不脯,包括連接、選擇刻诊、重復(fù)等防楷,并可以很容易地進(jìn)行擴(kuò)展,只需添加新的Regex實(shí)現(xiàn)類(lèi)则涯。

我們可以手動(dòng)調(diào)用方法來(lái)構(gòu)造任意復(fù)雜的Regex复局,但是,當(dāng)表達(dá)式比較復(fù)雜時(shí)粟判,手動(dòng)構(gòu)造的方式還是比較麻煩亿昏,所以下面實(shí)現(xiàn)了一個(gè)簡(jiǎn)易的正則表達(dá)式語(yǔ)法解析器RegexParser,它使用遞歸下降算法把一個(gè)正則表達(dá)式的字符串轉(zhuǎn)換成一個(gè)Regex對(duì)象档礁。這個(gè)解析器僅僅支持有限的正則表達(dá)式元素角钩,包括普通字符、括號(hào)優(yōu)先級(jí)呻澜,以及.递礼、*+等元字符羹幸。有興趣的讀者可以很容易地進(jìn)行擴(kuò)展脊髓。

public class RegexParser {
    private final String expr;
    private int index;

    public RegexParser(String expr) {
        this.expr = expr;
    }

    private void init() {
        index = 0;
    }

    private char peek() {
        return expr.charAt(index);
    }

    private char next() {
        return expr.charAt(index++);
    }

    private void read(char c) throws RegexParseException {
        if (c != next()) {
            throw new RegexParseException("expected: " + c);
        }
    }

    private boolean end() {
        return index == expr.length();
    }

    public Regex parse() throws RegexParseException {
        init();
        try {
            return parseExpr();
        } catch (RegexParseException e) {
            throw e;
        } catch (Exception e) {
            throw new RegexParseException("unknown error: " + e.getMessage());
        }
    }

    // elem = char | (expr)
    private Regex parseElem() throws RegexParseException {
        if (peek() == '(') {
            next();
            Regex r = parseExpr();
            read(')');
            return r;
        } else if (peek() == '.') {
            next();
            return any();
        } else {
            return ch(next());
        }
    }

    // factor = elem* | elem+ | elem
    private Regex parseFactor() throws RegexParseException {
        Regex r = parseElem();
        if (!end() && peek() == '*') {
            r = r.zeroOrMore();
            next();
        } else if (!end() && peek() == '+') {
            r = r.oneOrMore();
            next();
        }
        return r;
    }

    // term = factor factor ... factor
    private Regex parseTerm() throws RegexParseException {
        Regex r = parseFactor();
        while (!end() && peek() != ')' && peek() != '|') {
            r = r.concat(parseFactor());
        }
        return r;
    }

    // expr = term|term|...|term
    private Regex parseExpr() throws RegexParseException {
        Regex r = parseTerm();
        while (!end() && peek() == '|') {
            next();
            r = r.or(parseTerm());
        }
        return r;
    }
}

同時(shí)在Regex接口中添加一個(gè)靜態(tài)方法:

public interface Regex {
    ...
    static Regex of(String expr) throws RegexParseException {
        return new RegexParser(expr).parse();
    }
}

然后就可以像下面這樣構(gòu)造一個(gè)正則表達(dá)式:

Regex r = Regex.of("((a|b)c*)+");

上面這行代碼生成的Regex等價(jià)于:

Regex r = ch('a').or(ch('b')).concat(ch('c').zeroOrMore()).oneOrMore();

項(xiàng)目地址

https://github.com/byx2000/RegexCombinator

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市睹欲,隨后出現(xiàn)的幾起案子供炼,更是在濱河造成了極大的恐慌,老刑警劉巖窘疮,帶你破解...
    沈念sama閱讀 219,490評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件袋哼,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡闸衫,警方通過(guò)查閱死者的電腦和手機(jī)涛贯,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,581評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)蔚出,“玉大人弟翘,你說(shuō)我怎么就攤上這事〗拘铮” “怎么了稀余?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,830評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀(guān)的道長(zhǎng)趋翻。 經(jīng)常有香客問(wèn)我睛琳,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,957評(píng)論 1 295
  • 正文 為了忘掉前任师骗,我火速辦了婚禮历等,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘辟癌。我一直安慰自己寒屯,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,974評(píng)論 6 393
  • 文/花漫 我一把揭開(kāi)白布黍少。 她就那樣靜靜地躺著寡夹,像睡著了一般。 火紅的嫁衣襯著肌膚如雪厂置。 梳的紋絲不亂的頭發(fā)上要出,一...
    開(kāi)封第一講書(shū)人閱讀 51,754評(píng)論 1 307
  • 那天,我揣著相機(jī)與錄音农渊,去河邊找鬼。 笑死或颊,一個(gè)胖子當(dāng)著我的面吹牛砸紊,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播囱挑,決...
    沈念sama閱讀 40,464評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼醉顽,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了平挑?” 一聲冷哼從身側(cè)響起游添,我...
    開(kāi)封第一講書(shū)人閱讀 39,357評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎通熄,沒(méi)想到半個(gè)月后唆涝,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,847評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡唇辨,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,995評(píng)論 3 338
  • 正文 我和宋清朗相戀三年廊酣,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片赏枚。...
    茶點(diǎn)故事閱讀 40,137評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡亡驰,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出饿幅,到底是詐尸還是另有隱情凡辱,我是刑警寧澤,帶...
    沈念sama閱讀 35,819評(píng)論 5 346
  • 正文 年R本政府宣布栗恩,位于F島的核電站透乾,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜续徽,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,482評(píng)論 3 331
  • 文/蒙蒙 一蚓曼、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧钦扭,春花似錦纫版、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,023評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至膀斋,卻和暖如春梭伐,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背仰担。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,149評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工糊识, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人摔蓝。 一個(gè)月前我還...
    沈念sama閱讀 48,409評(píng)論 3 373
  • 正文 我出身青樓赂苗,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親贮尉。 傳聞我的和親對(duì)象是個(gè)殘疾皇子拌滋,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,086評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容