在幾乎每一種計算機程序語言中,解析字符串都是我們不得不面對的問題。有時這些字符串以一種簡單的格式出現(xiàn),有時它們又變得很復(fù)雜。我們將利用多種方法把字符串轉(zhuǎn)換成我們需要的東西。下面,我們將討論正則表達(dá)式、掃描器、解析器以及在什么時候使用它們。
首先,介紹一點點背景知識:解析一個字符串,其實就是用特定的語言來描述它。例如:把 @"42"
解析成數(shù)字,我們會采用自然數(shù)來描述這個字符串。語言都是用語法來描述的,語法其實就是一些規(guī)則的集合,這些規(guī)則可以用字符串來描述。比如自然數(shù),僅僅有一條規(guī)則:字符串的描述就是一個數(shù)字序列。這種語言也可以用標(biāo)準(zhǔn) C 函數(shù)或者正則表達(dá)式來描述。如果我們用正則表達(dá)式來描述一種語言,我們就可以說它有正則語法。
假設(shè)我們有一個表達(dá)式:"1 + 2 * 3",解析它就不容易。像這種表達(dá)式,我們可以用歸納語法來描述。換句話說,就是有一種語法,它的規(guī)則就是指的是它們自己,有時候甚至是遞歸的方式。為了識別這種語法,我們有三個規(guī)則:
x
是語言的成員,同時 y
也是語言的成員,那么 x+y
也是語言的成員。x
是語言的成員,同時 y
也是語言的成員,那么 x*y
也是語言的成員。使用這種語法描述的語言稱之為上下文無關(guān)文法 (context-free grammars),或者簡稱 CFG 1。需要注意的是這種語法不能使用正則表達(dá)式來解析(雖然一些正則表達(dá)式能實現(xiàn),如 PCRE,但這遠(yuǎn)遠(yuǎn)超越了一般的正則語法)。一個經(jīng)典的例子就是括號匹配,它可以用 CFG 來解析,卻不能用正則表達(dá)式 2。
像數(shù)字,字符串和時間這些,就可以用正則語言來解析。意思是說你可以使用正則表達(dá)式(或者相似的技術(shù))去解析它們。
像郵箱地址,JSON,XML 以及其它大多數(shù)的編程語言,都不能夠使用正則表達(dá)式來解析 3。我們需要一個真正的解析器來解析它們。大多數(shù)時候,我們需要的解析器就有現(xiàn)成的。蘋果就已經(jīng)為我們提供了 XML 和 JSON 解析器,如果想要解析 XML 和 JSON,用蘋果的就可以了。
當(dāng)你想要去識別一些簡單的語言時,正則表達(dá)式是一個好工具。但是,它們經(jīng)常被濫用在一些不適合它們的地方,比如 HTML 的解析?,F(xiàn)在假定我們有一個文件, 其中包含一個簡單的定義顏色的變量,設(shè)計者們可以利用該變量來改變你 iPhone app 中的顏色。格式如下:
backgroundColor = #ff0000
想要解析這種格式,我們就可以用正則表達(dá)式。正則表達(dá)式中最重要的是模式(pattern
)。如果你不知道什么是正則表達(dá)式,我們將很快的重新溫習(xí)一下,但是完全的解釋什么是正則表達(dá)式已經(jīng)超出了這篇文章的范圍。首先,我們來看一下 \\w+
, 它的意思是匹配任何一個數(shù)字、字母或者是下劃線至少一次(\\w
代表匹配任意一個數(shù)字、字母或者是下劃線,+
代表至少匹配一次)。然后,為了確保我們以后可以使用匹配的結(jié)果,需要用括號將它括起來,創(chuàng)建一個捕獲組(capture group)。接下來是一個空格符,一個等號,又一個空格符和一個 # 號。然后,我們需要匹配 6 個十六進制數(shù)字。\\p{Hex_Digit}
意思是匹配一個十六進制數(shù)字(Hex_Digit
是一個 unicode 屬性名)。修飾符 {6}
意味著我們需要匹配 6 個,然后和之前一樣,把這些一起用括號括起來,這樣就創(chuàng)建了第二個捕獲組:
NSError *error = nil;
NSString *pattern = @"(\\w+) = #(\\p{Hex_Digit}{6})";
NSRegularExpression *expression = [NSRegularExpression regularExpressionWithPattern:pattern
options:0
error:&error];
NSTextCheckingResult *result = [expression firstMatchInString:string
options:0
range:NSMakeRange(0, string.length)];
NSString *key = [string substringWithRange:[result rangeAtIndex:1]];
NSString *value = [string substringWithRange:[result rangeAtIndex:2]];
上面我們創(chuàng)建了一個正則表達(dá)式對象,讓它匹配一個字符串對象 string
,通過 rangeAtIndex
方法可以獲取用括號捕獲的兩組數(shù)據(jù)。在匹配的結(jié)果對象中,索引 0 是正則表達(dá)式對象自己,索引 1 是第一個捕獲組,索引 2 是第二個捕獲組,依此類推。最后,我們獲取到的 key
的值是 backgroundColor
,value
的值是 ff0000
。上面的正則表達(dá)式只解析了一行,下一步我們將要解析多行,并添加一些錯誤檢查。比如,輸入如下:
backgroundColor = #ff0000
textColor = #0000ff
首先,利用換行符將輸入字符串分隔開,然后遍歷返回的數(shù)組,并將解析的結(jié)果添加到我們的字典中,最后我們將生成這樣一個字典:@{@"backgroundColor": @"ff0000", @"textColor": @"0000ff"}
。下面是具體的代碼:
NSString *pattern = @"(\\w+) = #([\\da-f]{6})";
NSRegularExpression *expression = [NSRegularExpression regularExpressionWithPattern:pattern
options:0
error:NULL];
NSArray *lines = [input componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
NSMutableDictionary *result = [NSMutableDictionary dictionary];
for (NSString *line in lines) {
NSTextCheckingResult *textCheckingResult = [expression firstMatchInString:line
options:0
range:NSMakeRange(0, line.length)];
NSString* key = [line substringWithRange:[textCheckingResult rangeAtIndex:1]];
NSString* value = [line substringWithRange:[textCheckingResult rangeAtIndex:2]];
result[key] = value;
}
return result;
說句題外話,將字符串分解成數(shù)組,你還可以用 componentsSeparatedByString:
這個方法,或者用 enumerateSubstringsInRange:options:usingBlock:
這個方法來枚舉子串,其中 option 這個參數(shù)應(yīng)該傳 NSStringEnumerationByLines
。
假如某一行數(shù)據(jù)沒有匹配上(比如,我們不小心忘記一個十六進制字符),我們可以檢查 textCheckingResult
對象是否為 nil,如果為 nil,就拋出一個錯誤,代碼如下:
if (!textCheckingResult) {
NSString* message = [NSString stringWithFormat:@"Couldn't parse line: %@", line]
NSDictionary *errorDetail = @{NSLocalizedDescriptionKey: message};
*error = [NSError errorWithDomain:MyErrorDomain code:FormatError userInfo:errorDetail];
return nil;
}
把一個字符串轉(zhuǎn)化為一個字典,還有一種方式就是使用掃描器。幸運的是,F(xiàn)oundation 框架為我們提供了 NSScanner
,一個易于使用的面向?qū)ο蟮腁PI。首先,我們需要創(chuàng)建一個掃描器:
NSScanner *scanner = [NSScanner scannerWithString:string];
默認(rèn)情況下,掃描器會跳過所有空格符和換行符。但這里我們只希望跳過空格符:
scanner.charactersToBeSkipped = [NSCharacterSet whitespaceCharacterSet];
然后,我們定義一個十六進制字符集。系統(tǒng)定義了很多字符集,但卻沒有十六進制字符集:
NSCharacterSet *hexadecimalCharacterSet =
[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"];
我們先寫一個沒有錯誤檢查的版本。掃描器的工作原理是這樣的:它接收一個字符串,并將光標(biāo)設(shè)置在字符串的開始處。然后調(diào)用掃描方法,像這樣:[sanner scanString:@"=" intoString:NULL]
。如果掃描成功,該方法會返回 YES
,光標(biāo)會自動后移。scanCharactersFromSet:intoString:
方法的工作原理和之前的相似,只不過它掃描的是字符集,并將掃描的結(jié)果放入第二個參數(shù)的字符串指針?biāo)赶虻牡刂分?。我們使?&&
對不同的掃描方法進行 “與” 操作。這種方式的好處是只有與 &&
操作符左邊的掃描成功時,&&
右邊的掃描方法才會被調(diào)用。
NSMutableDictionary *result = [NSMutableDictionary dictionary];
while (!scanner.isAtEnd) {
NSString *key = nil;
NSString *value = nil;
NSCharacterSet *letters = [NSCharacterSet letterCharacterSet];
BOOL didScan = [scanner scanCharactersFromSet:letters intoString:&key] &&
[scanner scanString:@"=" intoString:NULL] &&
[scanner scanString:@"#" intoString:NULL] &&
[scanner scanCharactersFromSet:hexadecimalCharacterSet intoString:&value] &&
value.length == 6;
result[key] = value;
[scanner scanCharactersFromSet:[NSCharacterSet newlineCharacterSet]
intoString:NULL]; // 繼續(xù)掃描下一行
}
return result;
接下來添加一個有錯誤處理的版本,我們可以在 didScan
該行后面開始寫。如果掃描不成功,我們就返回 nil,并設(shè)置相應(yīng)的 error
參數(shù)。在解析文本時,當(dāng)輸入字符串格式不正確時,這個時候應(yīng)該怎么辦呢?是讓解析器崩潰,將錯誤值呈現(xiàn)給用戶,還是嘗試從錯誤中恢復(fù),這值得我們仔細(xì)地思考清楚:
if (!didScan) {
NSString *message = [NSString stringWithFormat:@"Couldn't parse: %u", scanner.scanLocation];
NSDictionary *errorDetail = @{NSLocalizedDescriptionKey: message};
*error = [NSError errorWithDomain:MyErrorDomain code:FormatError userInfo:errorDetail];
return nil;
}
C 語言也提供了具有掃描器功能的函數(shù),例如 sscanf
(可以用 man sscanf
查看怎么使用)。它遵循和 printf
類似的語法,只不過操作是逆序的(它是解析一個字符串, 而不是生成一個)。
如果設(shè)計者希望像 (100,0,255)
這樣來定義 RGB 顏色,該怎么辦呢?我們必須讓解析顏色的方法更智能一些。事實上,在完成后面的代碼后,我們就已經(jīng)會寫一個基本的解析器了。
首先,我們將添加一些方法到我們類中,并聲明一個屬性,類型為 NSScanner
。第一個方法是 scanColor:
,其作用是掃描十六進制的顏色值(例如 ff0000
)或者 RGB 元組,例如(255,0,0)
:
- (NSDictionary *)parse:(NSString *)string error:(NSError **)error
{
self.scanner = [NSScanner scannerWithString:string];
self.scanner.charactersToBeSkipped = [NSCharacterSet whitespaceCharacterSet];
NSMutableDictionary *result = [NSMutableDictionary dictionary];
NSCharacterSet *letters = [NSCharacterSet letterCharacterSet]
while (!self.scanner.isAtEnd) {
NSString *key = nil;
UIColor *value = nil;
BOOL didScan = [self.scanner scanCharactersFromSet:letters intoString:&key] &&
[self.scanner scanString:@"=" intoString:NULL] &&
[self scanColor:&value];
result[key] = value;
[self.scanner scanCharactersFromSet:[NSCharacterSet newlineCharacterSet]
intoString:NULL]; // 繼續(xù)掃描下一行
}
}
scanColor:
這個方法非常簡單。首先,它試圖掃描一個十六進制的顏色值,如果失敗,它會嘗試掃描 RGB 元組:
- (BOOL)scanColor:(UIColor **)out
{
return [self scanHexColorIntoColor:out] || [self scanTupleColorIntoColor:out];
}
掃描一個十六進制顏色和之前是一樣的。唯一的區(qū)別是我們將其封裝在一個方法中, 并且使用的都是 NSScanner
的方法。它會返回一個 BOOL
值表示掃描成功,并將結(jié)果存儲到一個指向 UIColor
對象的指針:
- (BOOL)scanHexColorIntoColor:(UIColor **)out
{
NSCharacterSet *hexadecimalCharacterSet =
[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"];
NSString *colorString = NULL;
if ([self.scanner scanString:@"#" intoString:NULL] &&
[self.scanner scanCharactersFromSet:hexadecimalCharacterSet
intoString:&colorString] &&
colorString.length == 6) {
*out = [UIColor colorWithHexString:colorString];
return YES;
}
return NO;
}
掃描基于 RGB 元組的顏色值也非常相似。在掃描 @"("
時,我們進行了與操作。在生產(chǎn)環(huán)境代碼中,我們可能需要更多的錯誤檢查,例如確保整數(shù)的范圍 0-255
:
- (BOOL)scanTupleColorIntoColor:(UIColor **)out
{
NSInteger red, green, blue = 0;
BOOL didScan = [self.scanner scanString:@"(" intoString:NULL] &&
[self.scanner scanInteger:&red] &&
[self.scanner scanString:@"," intoString:NULL] &&
[self.scanner scanInteger:&green] &&
[self.scanner scanString:@"," intoString:NULL] &&
[self.scanner scanInteger:&blue] &&
[self.scanner scanString:@")" intoString:NULL];
if (didScan) {
*out = [UIColor colorWithRed:(CGFloat)red/255.
green:(CGFloat)green/255.
blue:(CGFloat)blue/255.
alpha:1];
return YES;
} else {
return NO;
}
}
寫一個掃描器,就是在邏輯上將多個可變的掃描值混合起來,并調(diào)用其它的一些方法。解析器不僅是一個非常吸引人的主題,還是一個強大的工具。一旦你知道如何編寫一個解析器,你就可以發(fā)明一些小語言,如定義樣式表、解析約束、查詢數(shù)據(jù)模型、描述業(yè)務(wù)邏輯,等等。關(guān)于這個話題 Fowler 寫了一本非常有趣的書,名為《領(lǐng)域特定語言》。
我們已經(jīng)有一個非常簡單的解析器,它可以從一個文件中的字符串中提取鍵值對,我們也可以使用這些字符串生成 UIColor
對象。但是還沒有完。要是設(shè)計者想要定義更多的事情,怎么辦?比如,假設(shè)我們有不同的文件,其中包含一些布局的約束,格式如下:
myView.left = otherView.right * 2 + 10
viewController.view.centerX + myConstant <= self.view.centerX
我們該如何解析這個呢?實踐證明正則表達(dá)式并不是最好的方法。
在我們進行解析之前,先把這個字符串進行標(biāo)記化是一個不錯的主意。標(biāo)記化就是將一個字符串轉(zhuǎn)換成一連串標(biāo)記 (token)的過程。 例如,myConstant = 100
被標(biāo)記化的結(jié)果可能會是 @[@"myConstant", @"=", @100]
。在大多數(shù)程序語言中, 標(biāo)記化就是刪除空白符并將相關(guān)的字符解析成標(biāo)記。在我們的語言中,標(biāo)記可以是標(biāo)識符(如 myConstant
或 centerX
),操作符(如 .
,+
或 =
)或數(shù)字(如 100
)。在標(biāo)記化之后,標(biāo)記會繼續(xù)被解析。
為了實現(xiàn)標(biāo)記化(有時也稱為詞法分析 lexing 或掃描 scanning),我們可以重用 NSScanner
類。首先,我們可以專注于解析只包含操作符的字符串:
NSScanner *scanner = [NSScanner scannerWithString:contents];
NSMutableArray *tokens = [NSMutableArray array];
while (![scanner isAtEnd]) {
for (NSString *operator in @[@"=", @"+", @"*", @">=", @"<=", @"."]) {
if ([scanner scanString:operator intoString:NULL]) {
[tokens addObject:operator];
}
}
}
下一步是識別像 myConstant
和 viewController
這樣的標(biāo)識符。為了簡單起見,標(biāo)識符只包含字母(沒有數(shù)字)。如下:
NSString *result = nil;
if ([scanner scanCharactersFromSet:[NSCharacterSet letterCharacterSet]
intoString:&result]) {
[tokens addObject:result];
}
如果這些字符被找到,scanCharactersFromSet:intoString:
這個方法會返回 YES
,然后我們將這些找到的字符添加到我們的標(biāo)記數(shù)組。我們快要完成了,唯一剩下的事情就是是解析數(shù)字了。幸運的是,NSScanner
也提供了一些方法。我們可以使用 scanDouble:
方法來掃描 double
類型數(shù)據(jù),并將其封裝成 NSNumber
對象然后添加到標(biāo)記數(shù)組:
double doubleResult = 0;
if ([scanner scanDouble:&doubleResult]) {
[tokens addObject:@(doubleResult)];
}
現(xiàn)在我們的解析器完成了,下面我們來進行測試:
NSString* example = @"myConstant = 100\n"
@"\nmyView.left = otherView.right * 2 + 10\n"
@"viewController.view.centerX + myConstant <= self.view.centerX";
NSArray *result = [self.scanner tokenize:example];
NSArray *expected = @[@"myConstant", @"=", @100, @"myView", @".", @"left",
@"=", @"otherView", @".", @"right", @"*", @2, @"+",
@10, @"viewController", @".", @"view", @".",
@"centerX", @"+", @"myConstant", @"<=", @"self",
@".", @"view", @".", @"centerX"];
XCTAssertEqualObjects(result, expected);
我們的掃描器可以對操作符,姓名,以及被封裝成 NSNumber
對象的數(shù)字創(chuàng)建獨立的標(biāo)記。完成這些之后,我們準(zhǔn)備進行第二步:把這個標(biāo)記數(shù)組解析成更有意義的一些東西。
我們之所以不能用正則表達(dá)式或掃描器來解決上述問題,是因為解析有可能失敗。假定我們現(xiàn)在有一個標(biāo)記:@“myConstant”
。在我們的解析函數(shù)中,我們并不知道這是約束表達(dá)式的開始還是一個常數(shù)定義。我們需要兩個都試一下,看看哪一個成功。我們可以手工來寫這個解析代碼,難倒是不難,但是寫出來的代碼就像一坨屎;或者我們可以使用更合適的工具:語法解析庫(parsing library)。
首先,我們需要語法分析庫能理解的方式來描述我們的語言。下面的代碼就是專為我們那個布局約束語言寫的解析語法,使用的是擴展的巴科斯范式(EBNF)寫法:
constraint = expression comparator expression
comparator = "=" | ">=" | "<="
expression = keyPath "." attribute addMultiplier addConstant
keyPath = identifier | identifier "." keyPath
attribute = "left" | "right" | "top" | "bottom" | "leading" | "trailing" | "width" | "height" | "centerX" | "centerY" | "baseline"
addMultiplier = "*" atom
addConstant = "+" atom
atom = number | identifier
有許多的 Objective-C 庫用于語法解析(參見 CocoaPods)。像 CoreParse 就提供了很多 Objective-C 的 API。然而,我們并不能直接將我們的語法應(yīng)用在它上面。CoreParse 一次僅僅只有一個解析器工作。這意味著每當(dāng)解析器需要在兩個規(guī)則之間做決定(比如 keyPath
規(guī)則)的時候,它會根據(jù)下一個標(biāo)記來做決定。如果事后我們發(fā)現(xiàn)它選錯了,那麻煩就大了。當(dāng)然有的解析器允許更模糊的語法,但性能損失很大。
為了確保能夠兼容語法分析庫,可以對我們的語法做一些重構(gòu)。 我們也可以將它轉(zhuǎn)換成標(biāo)準(zhǔn)的巴科斯范式(BNF),下面的代碼就是 CoreParse 支持的格式:
NSString* grammarString = [@[
@"Atom ::= num@'Number' | ident@'Identifier';",
@"Constant ::= name@'Identifier' '=' value@<Atom>;",
@"Relation ::= '=' | '>=' | '<=';",
@"Attribute ::= 'left' | 'right' | 'top' | 'bottom' | 'leading' | 'trailing' | 'width' | 'height' | 'centerX' | 'centerY' | 'baseline';",
@"Multiplier ::= '*' num@'Number';",
@"AddConstant ::= '+' num@'Number';",
@"KeypathAndAttribute ::= 'Identifier' '.' <AttributeOrRest>;",
@"AttributeOrRest ::= att@<Attribute> | 'Identifier' '.' <AttributeOrRest>;",
@"Expression ::= <KeypathAndAttribute> <Multiplier>? <AddConstant>?;",
@"LayoutConstraint ::= lhs@<Expression> rel@<Relation> rhs@<Expression>;",
@"Rule ::= <Atom> | <LayoutConstraint>;",
] componentsJoinedByString:@"\n"];
如果一個規(guī)則被匹配了,那么這個解析器就試圖找到具有同樣名稱的類(如 Expression
)。如果這個類實現(xiàn)了 initWithSyntaxTree:
方法,那么該方法就會被調(diào)用。另外,解析器還有一個委托,當(dāng)有一個規(guī)則被匹配上或者發(fā)生錯誤時,委托都會被調(diào)用。舉例來說,我們先來看一下 CPSyntaxTree
類,它的第一個子節(jié)點是一個關(guān)鍵字標(biāo)記(調(diào)用 keyword
方法獲?。赡馨?@"="
,@">="
或者 @"<="
中的任意一個。屬性 layoutAttributes
是一個字典,它的 key
是一個字符串,value
是一個關(guān)于布局的 NSNumber
對象:
- (id)parser:(CPParser *)parser didProduceSyntaxTree:(CPSyntaxTree *)syntaxTree
NSString *ruleName = syntaxTree.rule.name;
if ([ruleName isEqualToString:@"Attribute"]) {
return self.layoutAttributes[[[syntaxTree childAtIndex:0] keyword]];
}
...
解析器的完整代碼在 GitHub,其中有一個類,大約 100 行代碼,我們可以用它解析復(fù)雜的布局約束,如:
viewController.view.centerX + 20 <= self.view.centerX * 0.5
我們會得到下面這樣的結(jié)果,它可以很容易地轉(zhuǎn)換成一個 NSLayoutConstraint
對象:
(<Expression: self.keyPath=(viewController, view),
self.attribute=9,
self.multiplier=1,
self.constant=20>
-1
<Expression: self.keyPath=(self, view),
self.attribute=9,
self.multiplier=0.5,
self.constant=0>)
除了 Objective-C 的庫,其他的一些工具比如 Bison,Yacc,Ragel,以及 Lemon,都是用 C 語言實現(xiàn)的。
另一件你可以做的事就是在 Build 時使用這些解析器生成一部分自己的代碼。例如,一旦你有了一種語言的解析器,你就可以創(chuàng)建一個簡單的命令行轉(zhuǎn)換工具。添加一個 Xcode 的 Build 規(guī)則,每一次 Build 時,你自己的語言就會被一起編譯。
語法分析看起來有一點奇怪,而且創(chuàng)建基于字符串的語言似乎并不是 Objective-C 的風(fēng)格。但事實恰恰相反,蘋果一直廣泛使用著基于字符串的語言。如 NSLog
格式化字符串,NSPredicate
字符串,可視化的布局約束格式語言,甚至是 KVC。所有這些都用了一些小的內(nèi)部解析器來解析字符串,并將其變成對象和方法。通常你不必自己編寫一個解析器,這大大節(jié)省了工作時間:常見的語言如 JSON 和 XML 都有通用的解析器。但是如果你想要編寫一個計算器,一種圖形語言,甚至是一個嵌入式的 Smalltalk,解析器大有幫助。