0基础——lisp学习笔记(一)

目录:

  1. Hello,world
  2. A Simple Database
  3. 语法和语义(待补充)
  4. 函数(Functions)
  5. 变量
  6. 序列变量的基本操作
  7. 标准宏
  8. 自定义宏(Macors)
  9. 数字、字符和字符串
    参考文献

本文以Practical Common LISP一书的学习笔记开始,逐渐深入讨论LISP语言

1. Hello, world

Common Lisp是lisp一个比较流行的方言,Common Lisp的官网在https://common-lisp.net/,官方推荐的入门版本又分为Steel Bank Common Lisp (SBCL) 和Clozure Common Lisp (CCL) ,这两个版本入门比较简单(wiki中有详细的方言列表:http://en.wikipedia.org/wiki/Common_Lisp#List_of_implementations)。

官网也提供了比较多的入门资料一些在线资料:

Practical Common Lisp(http://www.gigamonkeys.com/book/

Lisp in Small Parts(http://lisp.plasticki.com/show?14F

Common Lisp: A Gentle Introduction to Symbolic Computation(http://www-cgi.cs.cmu.edu/afs/cs.cmu.edu/user/dst/www/LispBook/index.html

Successful Lisp: How to Understand and Use Common Lisp(http://www.psg.com/~dlamkins/sl/cover.html)。

本人选择CCL作为入门语言,在官网下载CCL编译器。

Lisp并不支持一般的表达式,如果想得到1+2的结果需要输入

(+ 1 2)才能得到3。下面的语句定义了hello world函数:

?(defun hello-world () (format t "hello,world"))
HELLO-WORLD

defun:定义函数;
Hello-world:函数名称;
():函数参数列表;
(format t “hello,world”):格式化字符串,声明为t。
调用hello-world函数要依照规则

? (hello-world)
hello,world
NIL

Lisp程序的默认扩展名为.lisp,假设有一个lisp程序文件名为hello.lisp,那么在lisp REPL中可以通过 (load "hello.lisp")调用,而
(load (compile-file "a.lisp"))可以预编译文件。

2. A Simple Database

2.1. LIST function

Lisp中最基本的结构就是列表,使用LIST函数定义列表方式如下:

? (list 1 2 3)
(1 2 3)

也可以为列表提供关键字,这样的列表称之为property list(属性列表,简称plist):

? (list :a 1 :b 2 :c 3)
(:A 1 :B 2 :C 3)

可以用getf函数获取plist中关键字对应的值:

? (getf (list :a 1 :b 2 :c 3) :a)
1
? (getf (list :a 1 :b 2 :c 3) :c)
3

现在自定义函数make-cd用来生成cd数据信息:

? (defun make-cd (title artist rating ripped)
(list :title title :artist artist :rating rating :ripped ripped))
MAKE-CD

调用make-cd生成cd数据信息:

? (make-cd "Roses" "Kathy Mattea" 7 t)
(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T)

2.2. 存储数据

一条单一的数据(还是未命名的),没有任何实际意义,因此需要一个更大的数据结构来存储数据。DEFVAR宏可以用来定义全局变量,默认值为nil(空)。

? (defvar *db* nil)
*DB* 

PUSH宏将新建的数据推到全局变量db中:

? (push (make-cd "Roses" "Kathy Mattea" 7 t) *db*)
((:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))

自定义函数add-record,减轻输入负担:

? (defun add-record (cd) (push cd *db*))
ADD-RECORD
? (add-record (make-cd "Fly" "Dixie Chicks" 8 t))
((:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T) 
(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))
? (add-record (make-cd "Home" "Dixie Chicks" 9 t))
((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T) 
(:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T) 
(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))

直接输入变量的名称,可以在命令行中显示变量内容:

? *db*
((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T)
 (:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T)
 (:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))

2.3. 格式化输出,神奇的format表达式

如果为了观察db的内容,直接输入db返回的结果太乱了,如果是下面这样就好了:

TITLE:    Home
ARTIST:   Dixie Chicks
RATING:   9
RIPPED:   T

TITLE:    Fly
ARTIST:   Dixie Chicks
RATING:   8
RIPPED:   T

TITLE:    Roses
ARTIST:   Kathy Mattea
RATING:   7
RIPPED:   T

接下来,请不要震惊,看神代码:

? (format t "~{~a:~10t~a~%~}~%" (make-cd "Rose" "Kathy Mattea" 7 t))
TITLE:    Rose
ARTIST:   Kathy Mattea
RATING:   7
RIPPED:   T

NIL

还是逐渐来分析这些奇怪的指令吧,首先所有的格式化指令都已开头(类似C语言中的%),a相当于将参数输出为人类可读的格式。在C语言中格式化输出变量,int对应%d,float对应%f,string对应%s,由于lisp没有具体的变量类型~a就好理解了。a是aesthetic的缩写。

? (format t "~a" "Dixie Chicks")
Dixie Chicks
NIL
? (format t "~a" :title)
TITLE
NIL
? (format t "~a:" "Title")
Title:
NIL
? (format t "~a:~10t~a" :artist "Dixie Chicks")
ARTIST:   Dixie Chicks
NIL
? (format t "~a+~a=~a" 1 2 (+ 1 2))
1+2=3
NIL

~10t就表示前一个输出的首字母(包括)距离后一个输出的首字母10个字符距离。

ARTIST:   Dixie Chicks
123456789a

~%表示换行:

? (format t "~a~%~a~%~a" 1 2 3)
1
2
3
NIL

{与}表示循环遍历list中的每一个元素,已{与}包含的格式输出:

? (format t "~{number:~a~%~}" (list 1 2 3 4 5 6 7 8 9 10))
number:1
number:2
number:3
number:4
number:5
number:6
number:7
number:8
number:9
number:10
NIL

现在回过头来看下面代码,是不是就可以理解了呢:

 (format t "~{~a:~10t~a~%~}~%" (make-cd "Rose" "Kathy Mattea" 7 t))

自定义函数dump-db来格式化输出db中的数据,下面代码中 唯一陌生的宏dolist用来遍历db中的每一个元素,并命名为cd。

? (defun dump-db ()
(dolist (cd *db*) (format t "~{~a:~10t~a~%~}~%" cd)))
DUMP-DB
? (dump-db)
TITLE:    Home
ARTIST:   Dixie Chicks
RATING:   9
RIPPED:   T

TITLE:    Fly
ARTIST:   Dixie Chicks
RATING:   8
RIPPED:   T

TITLE:    Roses
ARTIST:   Kathy Mattea
RATING:   7
RIPPED:   T

NIL

再思考一下,其实利用{与}可以省略dolist:

? (defun dump-db () (format t "~{~{~a:~10t~a~%~}~%~}" *db*))
DUMP-DB
? (dump-db)
TITLE:    Home
ARTIST:   Dixie Chicks
RATING:   9
RIPPED:   T

TITLE:    Fly
ARTIST:   Dixie Chicks
RATING:   8
RIPPED:   T

TITLE:    Roses
ARTIST:   Kathy Mattea
RATING:   7
RIPPED:   T

NIL

2.4. 用户输入

开发软件主要就是解决数据结构、用户交互、数据存储等,数据结构已经解决了,用户交互的输出显示也解决了,那么这一节用来解决用户输入。
首先介绍两个函数force-output和read-line。force-output用来等待缓存的输出,在输出完成前不做任何处理(个人感觉,现在计算机的运行速度force-output的作用不明显,但还是有必要的)。read-line用来读取当前光标位置至本行结尾(不包括换行符)。
现在用format、force-out、read-line完成自定义函数prompt-read,用来处理CD的某一个属性的输入。

?(defvar *query-io*)
?(defun prompt-read (prompt)
(format *query-io* "~a: " prompt)
(force-output *query-io*)
(read-line *query-io*))
PROMPT-READ

需要注意通过上面自定义函数的输入都是字符串:

? (prompt-read "Title")
Title: blackjack
"blackjack";带””,是字符串
NIL

在数据输入时,字符串都好说,但数字就需要转化了。parse-integer用来将字符串转化为数字,但如果字符串无法转化为数字将报错终止程序(并且字符串得是完全的数字组成)。为parse-integer添加junk-allowed参数将允许字符串中包含非数字,数字开头的字符串将成功转化,非数字开头的字符串将返回NIL。

? (parse-integer "a10" :junk-allowed t)
NIL
0
? (parse-integer "10a" :junk-allowed t)
10
2

如果返回了NIL,我们默认将其看作是0,那么就需要借助OR宏,OR可以有多个参数,lisp将逐一计算每一个参数,返回第一个非NIL的结果。

? (or NIL NIL 1 2)
1
? (or (parse-integer "a10" :junk-allowed t) 0)
0
? (or (parse-integer "10a" :junk-allowed t) 0)
10

函数Y-OR-N-P用来强制用户输入y或Y或n或N,如果不是将不断循环,输入y或Y返回T,输入n或N返回NIL。另外y-or-n-p可以代一个参数,不带参数使提示(y or n),代参数时另外提示参数内容。

? (y-or-n-p)
 (y or n) k
Please answer y or n. (y or n) n
NIL
? (y-or-n-p)
 (y or n) y
T
? (y-or-n-p "Hi,")
Hi, (y or n) y
T

下面可以定义prompt-for-cd函数用来处理cd的属性输入了。

? (defun prompt-for-cd ()
(make-cd
(prompt-read "Title")
(prompt-read "Artist")
(or (parse-integer (prompt-read "Rating") :junk-allowed t) 0)
(y-or-n-p "Ripped [y/n]: ")))
PROMPT-FOR-CD

借助y-or-n-p函数可以用来实现不断的录入cd内容,直至用户不想录入。

? (defun add-cds ()
(loop (add-record (prompt-for-cd))
(if (not (y-or-n-p "Another? [y/n]: ")) (return))))
ADD-CDS
? (add-cds)
Title: blackjack
Artist: song
Rating: 2
Ripped [y/n]: (y or n) y
Another? [y/n]: (y or n) y
Title: stack
Artist: song
Rating: 3
Ripped [y/n]: (y or n) y
Another? [y/n]: (y or n) n
NIL

现在来查看一下数据库(内存)中的内容。

? (dump-db)
TITLE: stack
ARTIST: song
RATING: 3
RIPPED: T

TITLE: blackjack
ARTIST: song
RATING: 2
RIPPED: T

TITLE: Home
ARTIST: Dixie Chicks
RATING: 9
RIPPED: T

TITLE: Fly
ARTIST: Dixie Chicks
RATING: 8
RIPPED: T

TITLE: Roses
ARTIST: Kathy Mattea
RATING: 7
RIPPED: T

NIL

2.5. 数据的存储

宏WITH-OPEN-FILE用来绑定文件流到一个变量,执行一些操作,然后关闭文件流,其中:

:direction :output;声明打开文件为了写
:if-exists :supersede;如果文件存在则重写
with-standard-io-syntax;表示标准的IO异常处理

自定义函数来保存数据:

(defun save-db (filename)
(with-open-file (out filename
:direction :output
:if-exists :supersede)
(with-standard-io-syntax
(print *db* out))));print宏用来将变量输出到流中

自定义函数来读取数据:

? (defun load-db (filename)
(with-open-file (in filename)
(with-standard-io-syntax
(setf *db* (read in)))))
LOAD-DB

setf宏用来给变量赋值,with-open-file的默认参数是:direction :input,因此无需特别声明。read行数用来读取流中的数据。

1.2. 查询数据库

实现查询数据库前,先介绍下REMOVE-IF-NOT函数,用来在list中排除不是判断条件的元素,返回满足判断条件的元素组成新的list。

如果没有满足判断条件的则返回boolean值NIL。下面的例子从数列中排除不是偶数的数字。

? (remove-if-not #'evenp '(1 2 3 4 5 6 7 8 9 10))
(2 4 6 8 10)

符号#’将后面的名称当做函数对待,否则将被当做变量。#’的意思就是“找到下面名称的函数”。(remove-if-not #'evenp '(1 2 3 4 5 6 7 8 9 10))相当于对列表中的每一个元素执行函数evenp,返回结果为NIL移除。

evenp函数相当于(lambda (x) (= 0 (mod x 2))),下面的返回是一样的,列表中的每一个元素被当做lambda表达式中的输入参数x,执行(= 0 (mod x 2))的运算,x模2等于0则返回ture否则返回NIL。lambda并非函数名也非宏名称,只是表示下面的内容被声明为匿名函数。

? (remove-if-not #'(lambda (x) (= 0 (mod x 2))) '(1 2 3 4 5 6 7 8 9 10))
(2 4 6 8 10)

现在回顾2.1节中介绍的getf方法,若要搜索:artist值为"Dixie Chicks"的cd,首先获取所有:artist值,(getf cd :artist)在判断是否等于"Dixie Chicks",(equal (getf cd :artist) "Dixie Chicks"),在利用remove-if-not筛选。

? (remove-if-not
 #'(lambda (cd) (equal (getf cd :artist) "Dixie Chicks")) *db*)
((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T) (:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T) (:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T))

整理为自定义函数:

(defun select-by-artist (artist)
 (remove-if-not
 #'(lambda (cd) (equal (getf cd :artist) artist))
 *db*))

PS:下面讨论一个并不美丽的实现方式,但是也表达了lisp的一种函数调用方式。利用匿名函数的特点,匿名函数定义是与defun不同,defun在程序执行段的内存中专门开辟出一段地址记录这个函数,在函数调用时程序指令指针会跳转到函数地址的位置执行,执行完毕再回到主程序的下一句指令。而匿名函数是在编译时将匿名函数的内容拷贝到当前的代码段。



因此利用这种特性,可以先将搜索器抽象出来,定义为自定义函数,再在select函数中调用选择器。程序编译时相当于将选择器中的代码拷贝到算则函数中:

?(defun artist-selector (artist)
  #'(lambda (cd) (equal (getf cd :artist) artist)))
;再自定义选择函数:
?(defun select (selector)
  (remove-if-not selector *db*))  
?(select (artist-selector "Dixie Chicks"))
((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T) (:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T) (:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T))

只是这样我们需要实现每一个selector,artist-selector、title-selector、rating-selector、ripped-selector。作为一个懒人,这样烦死了。
再次回顾(getf (list :a 1 :b 2) :a)这个例子,其中选择关键字:a可否用变量替代呢。

? (defvar key :a)
KEY
? (getf (list :a 1 :b 2) key)
1

这样可以将select-by-artist函数改造成利用这种特性,这样就不用实现每一种属性的索引了:

  (defun select (key value)
  (remove-if-not
   #'(lambda (cd) (equal (getf cd key) value))
   *db*))

2.7 多重查询

前面已经讨论了基本的查询功能,但是只能查询一个条件。我们往往想一次查询多个条件已更精准的查找(例如select :title “Home” :artist “song”)。那么此时函数的输入参数数量是不定的,像lisp中,(+ 1 2 ..)、(and t nil t..)等等都可以输入多个参数,要实现这样的功能,需要在定义函数时运用lisp的&rest 参数功能,例如下面的函数计算了所有输入参数的和:

(defun mysum (&rest numbers)
(loop while numbers
      summing(pop numbers)))
? (mysum 0 1 2 3)
6

下面介绍另一个比较炫的功能,“”符号。首先输入(and 0 1)看看输出是什么?再输入一下(and 0 1),将`的语句原封不动的输出了,这是什么意思呢?。

? (and 1 0)
0
? `(and 1 0)
(AND 1 0)

其实这是macro的一种高级应用,下面定义一个宏,看看结果。

? (defmacro mymacro ()
`(and 1 0))
MYMACRO
? (mymacro)
0

虽然这样定义的宏运行结果与没有`符号相同,但是是有本质区别的。

? (defmacro mymacro ()
(and 1 0))
MYMACRO
? (mymacro)
0

我们可以利用这种特性,动态生成要执行的语句,然后在宏中执行。下面介绍下,@符号,在`中,@符号的意思是分割表达式中的值。

? `(and ,(list 1 2 3)
)
(AND (1 2 3))
? `(and ,@(list 1 2 3))
(AND 1 2 3)

那么现在来写一段没有任何意义仅仅为了表示用法的代码,可以看到只有在宏中才会执行的到结果:

? (defmacro myand (&rest a)
`(and ,@a))
MYAND
? (myand t nil t)
NIL
? (defun myand2 (&rest a)
`(and ,@a))
MYAND2
? (myand2 t nil t)
(AND T NIL T)

利用上面介绍的内容就可以逐渐的实现多重查询了,首先自定义函数创造表达式:

(defun make-expr (key value)
  `(equal (getf cd ,key) ,value))
? (make-expr :artist "song")
(EQUAL (GETF CD :ARTIST) "song")

处理多个输入创建表达式列表:

(defun make-expr-list (fields)
  (loop while fields
    collecting (make-expr (pop fields) (pop fields))))
? (make-expr-list (list :artist "song" :title "home"))
((EQUAL (GETF CD :ARTIST) "song") (EQUAL (GETF CD :TITLE) "home"))

执行拼凑好的语句的select宏:

(defmacro select (&rest selector)
  `(remove-if-not #'(lambda (cd) (and ,@(make-expr-list selector))) *db*))
? (select :artist "song" :title "Home")
((:TITLE "Home" :ARTIST "song" :RATING 2 :RIPPED NIL))

2.8. 更新数据

select宏将满足查询条件的结果返回成新的list,但是可以对新的list进行操作,而影响原始list。这样我们可以像
(update (select :artist “song”) :rating 10) 这样更新数据库。
自定义函数update,使用临时变量保存select结果,然后用dolist遍历并改变对应属性的值。

(defun update (selector-fn key value)
  (defvar tmp nil)
  (setf tmp selector-fn)
  (dolist (cd tmp)
    (setf (getf cd key) value))
  (print-db tmp)) 
? (update (select :artist "song") :rating 10)
TITLE:    Home
ARTIST:   song
RATING:   10
RIPPED:   NIL

TITLE:    blackjack
ARTIST:   song
RATING:   10
RIPPED:   T

NIL

但是上面的函数仅能修改一个属性的值,我们还是希望能够修改多个属性,那么添加一个函数,专门处理多属性值的修改。

(defun make-updates (cd newKV)
  (loop while newKV
    do (setf (getf cd (pop newKV)) (pop newKV))))

在update函数中调用make-update即可。

(defun update (selector-fn &rest newKV)
  (defvar tmp nil)
  (setf tmp selector-fn)
  (dolist (cd tmp)
    (make-updates cd newKV))    
  (print-db tmp))
? (update (select :artist "song") :rating 18 :ripped nil)
TITLE:    Home
ARTIST:   song
RATING:   18
RIPPED:   NIL

TITLE:    blackjack
ARTIST:   song
RATING:   18
RIPPED:   NIL

NIL

写出这种update函数的实现后,再回想select宏的实现,其实也可能按照常规的编程思路去写,只不过lisp的思路写代码更简洁更高效。就是太难理解了,仅从入门样例中还无法学到精髓。这里的update实现先这样,等逐渐的深入了解lisp后,再进行改造,更多的运用lisp特性。

2.9. 删除数据

与remove-if-not对应的一个函数是remove-if,这将返回与判断条件相违的结果,那么利用remove-if来实现删除数据再好不过了,实现与select基本一致。

(defmacro delect (&rest selector)
  `(setf *db* (remove-if #'(lambda (cd) (and ,@(make-expr-list selector))) *db*)))
? (delect :title "Home" :artist "song")
((:TITLE "blackjack" :ARTIST "song" :RATING 2 :RIPPED T) (:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T) (:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T) (:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))

2.10. 完整代码

下面是第二章所开发简易数据库的完整代码:



©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354