本文相关的知识点主要来自 elisp 简明教程 后续内容可以直接查看这个教程
上一节我们了解了elisp中基础数据类型之一的数字类型,相比于C/C++ 来说elisp的数字类型更少,学习起来可能也更加简单。那么这篇我们来学习另一个数据类型——字符串
字符串的基本介绍
回忆以下在C/C++中学到的关于字符的知识,字符采用char
来表示,它占一个字节,里面存储的是各个字符的编码。当然针对汉字或者其他东亚文字,一个char
可能表达不了,它会用 2个或者3个字节来表示一个汉字。后来又有unicode字符,和wchar_t
类型。而字符串则是以0结尾的字符数组。C/C++中经常会出现这样的经典考题
char* pszStr = "Hello, World";
char szBuf[] = "Hello, World";
它们分别占几个字节,它的考点主要有两个,第一就是指针类型存储的就是地址,它与具体的机器结构有关x86机器上占4个字节。第二个考点就是字符串里面藏了一个0作为字符串的结尾
char szBuf[] = "Hello\0Word";
这样的字符串虽然可以表达出来,但是我们通过 strlen
之类的函数,得到的结果却是5。因为遇到0就结束了。
elisp中的字符串与C/C++中最大的不同就是elisp中字符串可以有 0。另外一个不同就是elisp中没有字符类型,字符串中每一个字符都是字符的unicode形式,用C/C++类比就是字符串通过GetAt
之类的函数返回的是字符的unicode整数值。当然严格意义上来说C/C++中的字符类型也是一个整数值。
elisp中可以使用?A
这样的形式来表示一个A
字符。最终得到的结果就是A
的ASCII码 65,我们可以使用之前学到的数字类型检测函数来判断它得到的是不是整数类型
(integerp ?A) ; ⇒ t
对于一些标点符号或者有歧义的字符,可以使用 \
进行转义,例如
?\'
?\"
?\\
对于一些没有歧义的标点符号加不加转义字符没有影响,但是为了美观或者同一或者说为了不增加记忆的负担,标点符号统一使用转义字符。
另外,我们可以在字符串中使用10进制、八进制、16进制的形式来表示字符,例如
(setq msg "\x68\x65\x6C\x6C\x6F\x2C\x20\x77\x6F\x72\x6C\x64") ; ⇒ "hello, world"
使用其他进制的写法如下:
- 十进制:使用 \d + 数字(不常用,主要用十六进制和八进制)。
- 十六进制:使用 \x + 两位十六进制数字。
- 八进制:使用 \0 + 三位八进制数字。
字符串函数
首先我们可以使用 length
来获取长度,它有点类似与 Python中的len
函数,它不光可以获取字符串类型的长度,还可以获取列表、向量等类型的长度
例如
(setq msg "hello, world")
(length msg) ;; ⇒ 12
我们在前面说过,字符串可以带上0,我们来测试一下有0的情况下,得到的长度如何
(setq msg "hello\x00world")
(length msg); ;; ==> 11
因为elisp并不以0作为字符串的结尾,实际上elisp字符串是以向量的形式存储的,向量中每个元素都是一个整数,所以这里返回的仍然是字符向量的大小。
我们可以使用 stringp
来测试一个变量是否为字符串。例如
(setq msg "hello")
(stringp msg) ; ⇒ t
(stringp ?A) ; ⇒ nil
我们也可以使用 string-or-null-p
函数来检测,顾名思义,它主要用来判断当前变量是否为字符串或者是一个nil
(char-or-string-p msg) ; ⇒ t
(char-or-string-p ?A) ; ⇒ t
(char-or-string-p nil) ; ⇒ nil
(char-or-string-p "") ; ⇒ t
但是遗憾的是 elisp 中没有判断字符串是否为空的方法,我们只能自己写代码来实现
(defun string-emptyp (str)(and (stringp str) (zerop (length str))))
这个函数判断当前传入对象是否是字符串,并且字符串长度为0。
构造函数
可以使用make-string
函数来构造一个字符串,它构造一个里面都是同样字符的字符串,例如
(make-string 5 ?A) ; ⇒ "AAAAA"
如果想要构造一个不同字符构成的字符串,可以使用 string
(string ?A ?B ?C) ; ⇒ "ABC"
也可以使用 substring
和 concat
来产生一个新的字符串,前者从字符串中取子串,后者连接两个字符串。
substring
接受一个字符串和两个整数,表示一个范围,是一个前开后闭的区间,也就是包含前面的范围不包含后面的范围。字符串的索引也是从0开始。
(substring "Hello, World" 3 5) ; ⇒ "lo"
也可以只包含一个起始位置,表示从这个位置开始往后的字符
(substring "Hello, World" 3) ; ⇒ "lo, World"
也可以传入负数,与Python中的索引类似,负数表示从右往左数,但是注意,最右边的字符是-1,因为0表示最左边的数
(substring "Hello, World" -5 -3) ; ⇒ "Wo"
concat
就相对比较容易理解一些,它就是将两个字符串合并成一个新串
(concat "hello" ", world") ; ⇒ "hello, world"
与C/C++类似的是,字符串定义之后无法更改,需要更改的话,它的做法是创建一个新的字符串,并且舍弃掉原来的字符串。所以这里将 substring
、concat
这种取子串和连接字符串的函数也归类到构造函数中,因为它们的的确确构造了一个新的字符串。
字符串比较
在C/C++中,比较字符串时使用 strcmp
函数。我们根据它的返回值来决定字符串是大于小于或者等于。在比较的时候从左往右,依次比较它们的编码值,直到遇到不一样的值。它仅仅比较编码值,而不关心字符串长度,只有在前面的字符都相等的时候才会根据长度判断。相信各位在学习C/C++的时候都亲手实现过strcmp
函数,这里就不展开了。elisp中字符串的比较函数就比较多了。
char-equal
比较两个字符是否相等,默认情况下它会忽略大小写,例如
(char-equal ?A ?a) ; ⇒ t
如果要大小写敏感的话就不能用这个函数了,那么大小写敏感的时候该怎么比较呢?这个时候千万别犯迷糊,字符本身就是一个整数,完全可以使用 =
或者 eql
直接判断
(eql ?A 65) ; ⇒ t
(= ?A 65); ⇒ t
判断字符串是否相等我们可以使用 string=
或者 string-equal
。它们二者是等价的,是同一个函数的不同叫法而已。
(setq foo "hello")
(setq bar "hello")
(string= foo bar) ; ⇒ t
使用字典顺序来比较字符串大小使用的是 string<
或者 string-lessp
。与前面类似,它们也是等价的。它的判断逻辑与 strcmp
函数相同。
(setq foo "Hello")
(setq bar "hello world")(string-lessp foo bar) ; ⇒ t(setq foo "Hello")
(setq bar "Hello")(string-lessp foo bar) ; ⇒ nil
比较遗憾的是没有 string>
这样的比较。如果想要判断是否大于的话,需要判断不相等并且不小于。
根据 string<
的比较逻辑来看,空串是最小的字符串,也就是任意非空字符串都比空串大,因此上面判断是否是空串的代码可以使用这一特性实现
(defun string-emptyp (str)(and (stringp str) (not (string< "" str))))
不知道各位是否还记得Java String类中有 Equal 函数和 ==
来比较字符串。其中Equal来比较字符串内容是否相等,而 ==
仅仅比较对象的地址是否相等。elisp中同样有这样的操作,我们使用 eq
来代替 ==
判断对象本身是否相等,对于简单类型也就是数字类型,我们使用它来判断数字是否相等,而对于字符串、列表、向量这种复杂类型时,判断它们的地址是否相等。
(setq foo ?A)
(setq bar ?A)
(eq foo bar) ; ⇒ t(setq foo "A")
(setq bar "A")
(eq foo bar) ; ⇒ nil
字符串转化
下面来介绍一些字符串和数字类型相互转换的函数
我们可以使用 char-to-string
来将一个整数转换成字符串,或者使用 string-to-char
来将字符串转化为整数,当然这个函数只会返回第一个字符的整数值。例如
(char-to-string 65) ;; ⇒ "A"
(string-to-char "Hello world") ;; ⇒ 72
使用string-to-char
只能获取字符串中第一个字符的值,如果我们要取字符串中任意位置的字符该怎么办呢?我们可以使用substring
来获取以对应位置开始的一个子串,然后获取这个子串的第一个字符
(defun get-string-char (str index)(string-to-char (substring str index)))(get-string-char "Hello World" 5) ; ⇒ 32
或者我们可以也使用 aref
函数,该函数用来取数组中任意位置的值,因为字符串也是一个数组,因此我们可以使用该函数来取字符串中任意位置的字符。例如
(aref "Hello World" 5) ;; ==> 32
另外我们可以将数字转化为对应的字符串或者将数字字符串转化为对应的整数。它们的功能类似于C/C++
中的 atoi
和 itoa
函数。
string-to-number
用来将字符串转化为数字,它可以支持从2到16进制的转化,例如
(string-to-number "ff" 16) ;; ⇒ 255
(string-to-number "A1") ;; ⇒ 0, 默认以10进制进行转化
(string-to-number "10" 2) ;; ⇒ 2
number-to-string
用于将数字转化为字符串,它只支持以10进制的形式转化
(number-to-string 256) ; ⇒ "256"
(number-to-string ?A) ;; ⇒ "65"
如果想以任意进制来将数字转化为字符串,那么可以使用 format
函数,它类似于C/C++
中sprintf
。用来格式化字符串,但是它只支持8进制10进制和16进制的转换
(format "%d" 256) ;; ⇒ "256"
(format "%#o" 256) ;; => "0400"
(format "%#x" 256) ;; ⇒ "0x100"
要转化成二进制的话,没有现成的函数可以用,不过我们可以自己实现,相信学过C/C++的应该写过类似的算法,不过我记得当初我学的算法是先入栈再出栈,下面的代码也是采用类似的方式。通过取模最先算出来的在最低位,所以我们在连接字符串的时候将计算的结果放到前面,连接上之前计算的结果
(defun number-to-binary (num)(if (= num 0)"0"(let ((binary ""))(while (> num 0)(setq binary (concat (number-to-string (mod num 2)) binary))(setq num (/ num 2)))binary)))(number-to-binary 256) ;; ⇒ 100000000
emacs-lisp 简明教程 中还介绍了其他类型的数据结构与字符串互相转化的函数,这里就不介绍了,等后面学到了再说。
另外字符串还有一些大小写转换的函数。使用 downcase
将字符串中的字母都转换为小写字母,使用 upcase
转换为大写字母。例如
(downcase "Hello World") ;; ⇒ "hello world"
(upcase "Hello World") ;; ⇒ "HELLO WORLD"
(downcase "你好,世界") ;; ⇒ "你好,世界"
(upcase "αβγ") ;; ⇒ ""ΑΒΓ""
这种字母文字它可以进行大小写转换,但是对于中文这种没有大小写字母的就不存在转化了。
使用 upcase-initials
来将字符串中每个单词的第一个字符大写,其余的字符它会忽略它。
(upcase-initials "hellO woRld") ;; ⇒ "HellO WoRd"
函数 captialize
会将字符串每个单词首字母大写,其余的转化成小写
(capitalize "hellO wORLD") ;; ⇒ "Hello World"
查找与替换
字符串最重要的操作还是查找和替换。elisp 中查找主要使用 string-match
。它使用正则表达式来进行查找。elisp中没有C/C++中find
那样查询子串的缩进的函数。查询子串其实也可以利用正则表达式来处理
(string-match "Emacs Lisp" "This is Emacs Lisp Program") ;; ⇒ 8
该函数的第一个参数是一个正则表达式,上面代码中我们直接使用字符串进行匹配,就是在精准的匹配子串。它返回子串开始的索引。
它还可以接收一个数字,表示从字符串的第几个字符开始往后进行查找,这个参数的作用有点像 String.Find(int index)
这个重载函数中 index
的含义。
(string-match "Emacs Lisp" "This is Emacs Lisp Program" 10) ;; ⇒ nil
但是如果查找的子串中有特殊符号的话,就不能这么使用
(string-match "2*" "232*3=696") ;; ⇒ 0
这里因为 *
被当成正则表达式的模糊匹配符号,它表示任意一个字符。如果想要它单纯的作为普通符号,可以使用regexp-quote
来处理一下,它的作用是将字符串中的所有特殊字符转义,使其可以安全地作为正则表达式使用。这样可以确保字符串的内容被视为字面量,而不是正则表达式中的元字符。
(string-match (regexp-quote "2*") "232*3=696") ; => 2
不知道各位读者使用过 C/C++
中的正则表达式没有,正则表达式匹配之后会产生一个结果对象,它包含了所有匹配上的位置。我们可以通过循环或者其他方式来得到这个位置,并且得到具体匹配上的结果。在elisp中,结果被保存在 match-data
中。它允许你获取最近一次正则表达式匹配的位置信息,包括匹配的起始和结束位置。我个人的感觉它有点像Win32 API
中的GetLastError
一样,每次调用其他API结果都会被覆盖,每次调用只能得到上一次的错误码。
(progn (string-match "3\\(4\\)" "01234567890123456789")(match-data)) ;; ⇒ (3 5 4 5)
我们使用 3(4)
这样的正则表达式来进行匹配。这里括号表示一个捕获组,它匹配34或者单独匹配4。match-data中每对数组表示一个匹配组的起始和结束位置,它是一个前闭后开区间。也就是包括前面的,不包括后面的。
上面的代码先匹配 34
,发现它在第3个字符处出现,所以它返回第一组数据 (3 5)
,然后匹配4,它出现在第4个字符,所以产生第二组数据 (4 5)
。按照这个思路,如果将正则表达式修改一下改为 3\\(4\\)\\(5\\)
将会产生3组数据,第一组匹配 345
,第二组匹配 4
,第三组匹配 5
。最后的结果就是 (3 6 4 5 5 6)
在上面说到 match-data
中每对数组表示一个匹配组的起始和结束位置。我们可以使用 match-beginning
和 match-end
来获取这两个数据,它需要一个整数作为参数,用来表示取第几组的数据,例如下面的代码
(progn (string-match "3\\(4\\)" "01234567890123456789")(message "%d" (match-beginning 0)) ;; => 3(message "%d" (match-end 0)) ;; ⇒ 5(message "%d" (match-beginning 1)) ;; ⇒ 4(message "%d" (match-end 1))) ;; ⇒ 5
如果我们给出的参数超过了匹配组的大小,那么它们将会返回 nil
,例如如果上述匹配我们使用 (match-beginning 2)
来取第三组的结果的话,将会得到nil
每一个匹配组都是一个半开半闭区间,因为 match-end
是匹配到的字符位置的下一个位置,所以使用它很容易进行循环。上述的代码在匹配的时候,只要是匹配到就停止了,我们可以写一个循环用来继续匹配后面的字符串
(let ((start 0))(while (string-match "34" "01234567890123456789" start)(message "find at %d\n" (match-beginning 0))(setq start (match-end 0))))
掌握了查找的相关操作之后,我们继续来学习有关替换的操作。我们可以使用 replace-match
来将匹配到的字符串替换成指定的字符串,它的函数原型如下
(replace-match NEWTEXT &optional FIXEDCASE LITERAL STRING SUBEXP)
我们主要需要关注的是 NEWTEXT
它代表的是我们希望用哪个字符串来替换匹配上的字符串,STRING
表示希望进行匹配的字符串,例如
(let ((str "hello world 123"))(string-match "\\([0-9]\\)" str)(replace-match "#" nil nil str)) ;; ⇒ "hello world #23"
我们也可以使用循环来将所有数字都替换成 #
(let ((str "hello world 123")(start 0))(while (string-match "\\([0-9]\\)" str start)(setq str (replace-match "#" nil nil str))(setq start (match-end 0)))str) ;; ⇒ "hello world ###"
需要注意的是,这里每次执行 replace-match
后都返回一个新的字符串,原来老的字符串保持不变,所以我们这里每次替换之后都手动的使用 setq
来对老的字符串进行赋值,然后再重新匹配。这样才能保证最后的结果是我们想要的结果。我们可以使用下面的代码来验证这一点
(let* ((str "hello world 123")(start 0)(final-str str))(while (string-match "\\([0-9]\\)" str start)(setq final-str (replace-match "#" nil nil str))(setq start (match-end 0)))final-str) ;; ⇒ "hello world 12#"
这里只替换了最后一个数字,这是因为执行 replace-match
之后的str
变量并没有被改变,每次都是之前的再进行匹配,唯一变化的只有 start
的值。最后一次我们仍然在使用原始的 str
字符串在进行匹配,它匹配到 3
。所以最后一次替换只将3
替换成了 #
。
我们也可以使用捕获组进行替换,例如我们想将 “hello world 123” 替换成 “123 hello world”,那么可以使用如下代码
(let ((str "hello world 123"))(string-match "\\(hello world\\) \\([0-9]+\\)" str)(replace-match "\\2 \\1" nil nil str)) ;; ⇒ "123 hello world"
这里使用 \\2 \\1
来表示替换的新字符串,它表示的含义是匹配组里面的第二组结果+空格+第一组结果。它们组成了一个新串用来替换原来的字符串。
replace-match
的最后一个参数表示替换哪一个捕获组,默认是0,例如上述的代码中,第0个捕获组就是整个字符串,所以它替换了整个字符串,我们可以将上述代码做一下修改
(let ((str "hello world 123"))(string-match "\\(hello world\\) \\([0-9]+\\)" str)(replace-match "\\2 \\1" nil nil str 1)) ;; ⇒ "123 hello world 123"
它将第一匹配组也就是 hello world
使用新字符串 123 hello world
来替换,然后加上原来剩下的字符串,最终也就得到结果 123 hello world 123
到这里,已经将字符串替换的常见操作都做了一些说明。本节的内容也就到此结束了。后面将继续按照 教程来学习。敬请期待!当然了,如果各位读者觉得我的这一系列教程有抄袭的嫌疑,或者质量不如原版,又或者更新缓慢,请按照对应的链接来学习相关内容。