热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

lua语言闭包、模式匹配、日期、编译、模块的特性及应用

本文介绍了lua语言中闭包的特性及其在模式匹配、日期处理、编译和模块化等方面的应用。lua中的闭包是严格遵循词法定界的第一类值,函数可以作为变量自由传递,也可以作为参数传递给其他函数。这些特性使得lua语言具有极大的灵活性,为程序开发带来了便利。

lua中的闭包

在lua语言中,函数是严格遵循词法定界(lexical scoping)的第一类值(first-class value)。

第一类值意味着lua语言中的函数与其它常见类型的值(例如数值和字符串)具有同等权限:一个程序可以将某个函数保存到变量、或表中,也可以将函数作为参数传递给另外一个函数,还可以将函数作为某个函数的返回值返回。

词法定界意味着,内层函数可以访问外层函数中的变量。

上面两个特性组合起来,为lua语言带来了极大的灵活性。




函数是第一类值


我们来看看lua中是如何体现函数是第一类值的

a = print
a("hello world") -- hello world
-- 函数可以作为一个变量自由传递
function f(func, str, substr)
print(func(str, substr))
end
f(string.find, "mashiro", "shi") -- 3 5
function f(func, str, start, end_)
print(func(str, start, end_))
end
f(string.sub, "satori", 2, -2) -- ator
-- 函数可以作为参数传递给另一个函数
function f()
return string.gsub
end
-- 函数也可以返回一个函数
print(f()("hello mashiro", "mashiro", "satori")) -- hello satori 1
-- 对于表也是一样的
t = {print, std = io.write }
t[1]("mashiro") -- mashiro
t["std"]("satori\n") -- satori
-- 两个函数也能替换
math.type = string.sub
print(math.type("xxx", 2, -2)) -- x

标准库table提供了一个sort函数,专门用来对表里面的内容进行排序的,但是怎么排,lua没有提供具体的方法,而是需要我们通过一个函数来指定

t = {
{name = "mashiro", age = 16},
{name = "satori", age = 17},
{name = "nagisa", age = 20},
{name = "kurisu", age = 18},
}
-- 会对元素进行比较,因为a.age > b.age的话,结果为true
-- 所以年龄大的,会排在前面。在python中,满足条件的排在后面
table.sort(t, function(a, b) return a.age > b.age end)
print(t[1].name, t[1].age) -- nagisa 20

像这种没有函数名的函数,我们也可以称之为匿名函数。匿名函数不能单独定义,需要作为值进行传递,比如放到表中、作为参数、或者使用变量进行接收等等。


我们看到table.sort它需要接收一个函数作为参数,因此像table.sort这样的函数我们也称之为是高阶函数




非全局函数


由于函数是第一类值,因此一个显而易见的结果就是: 函数不仅可以被存储在全局变量中,还可以存储在表字段和局部变量中。

lib = {}
function add(a, b)
return a + b
end
lib.a = add
print(lib.a(1, 22)) -- 23

上面是将函数存储在表中,我们在上面已经见识了,下面看看将函数存储在局部变量中是什么样子的。

function outer(a, b)
function inner(c)
return a + b + c
end
-- 这里必须要返回inner,否则是无法调用inner的
return inner
end
-- 上面我们就实现了一个闭包
print(outer(1, 2)(33)) -- 36
-- 但是问题来了,我们看到inner居然可以直接调用
-- 因为我们说不管在什么地方,只要没有local关键字,定义的都是全局的
-- 所以尽管不能上来就调用inner,但是当outer调用之后,inner就会被加入到全局变量中
-- 可问题是,结果为啥是6,这是因为inner里面需要a和b,所以当outer调用之后,inner就记住了a和b的值
-- 而我们上面给outer的a和b传递的值是1和2,因此再调用inner的时候,a和b还是1和2
print(inner(3)) -- 6
print(inner(8)) -- 11
-- 重新执行outer,那么inner会被重新定义
-- 此时inner记住的a和b就变成了11和22
print(outer(11, 22)(33)) -- 66
print(inner(1)) -- 34

那么如何将函数定义成局部的呢?

function outer(a, b)
local function inner(c)
return a + b + c
end
-- 这里必须要返回inner,否则是无法调用inner的
return inner
end
-- 此时就将inner定义成局部的了,因为我们使用了local关键字
-- 所以我们看到local也可以作用于函数
print(outer(1, 2)(3)) -- 6
print(inner) -- nil
-- 那如何将函数保存起来呢
function f1(a)
local f2 = function(b) return a + b end
return f2
end
print(f1(1)(22)) -- 23
print(f2) -- nil

将函数保存在局部变量中有一个坑,我们来看看

function f1()
local function f2(n)
if n == 1 then
return 1
else
return n + f2(n - 1)
end
end
return f2
end
-- 上面是一个简单的求和,通过递归来实现
print(f1()(100)) -- 5050
-- 如果是下面这种形式呢
function f1()
local f2 = function(n)
if n == 1 then
return 1
else
return n + f2(n - 1)
end
end
return f2
end
-- 我们代码和上面的版本没有太大区别,只不过将函数使用局部变量保存了
-- 执行的时候会出现什么结果呢?
f1()(100)

上面递归求和的第二个版本是否存在问题呢?答案是存在的,当调用内部的n + f2(n - 1)的时候,你会发现f2不存在。为什么是这个结果,也很好理解,我们用右边那一大坨函数赋值给f2,但是函数里面在赋值给f2的同时,里面已经就引用了f2。所以此时的f2是一个nil,或者说会去外部去找这个f2。

function f1()
local f2
f2 = function(n)
if n == 1 then
return 1
else
return n + f2(n - 1)
end
end
return f2
end
-- 我们代码和上面的版本没有太大区别,只不过将函数使用局部变量保存了
-- 执行的时候会出现什么结果呢?
print(f1()(100)) -- 5050

此时就没有问题了,因为我们先定义了一个局部变量f2,尽管定义的时候f2还没有正确的值,但是一旦当执行函数的时候,f2就已经有了正确的值。




词法定界


关于词法定界,其实我们上面已经见识到了, 就是内层函数访问外层函数的变量

a = "xxx"
function f1()
local a = 123
local function f2()
return a
end
return f2
end
-- 此时返回的是f1中的a,内层函数可以访问外层函数的变量
print(f1()()) -- 123
a = "xxx"
function f1()
a = 123
local function f2()
return a
end
return f2
end
-- f1中的a和全局的是一个a
print(f1()(), a) -- 123 123
a = "xxx"
function f1()
local a = 123
local function f2()
local a = ">>>"
return a
end
return f2
end
-- 自身有a这个变量,就不会到外界去找了
print(f1()()) -- >>>

来个小栗子:

function f1(str)
local function f2(substr)
return string.find(str, substr)
end
return f2
end
f2 = f1("abcde")
print(f2("ab")) -- 1 2
print(f2("bcd")) -- 2 4
print(f2("abcde")) -- 1 5

lua中的模式匹配

下面来介绍一下lua中的模式匹配,这个很明显是针对字符串的,但是与其它脚本语言不同,lua中并没有正则表达式来进行模式匹配(pattern matching),lua没有提供POSIX风格的正则、也没有提供Perl风格的正则。


之所以这么做的主要原因是大小问题,一个典型的POXIS正则表达式实现需要超过4000行代码,这比lua标准库所有代码的一半还要多。

而lua提供的模式匹配的实现,代码只有不到600行。尽管无法实现正则的所有功能,但也是非常强大的,并且同时还具有和POXIS不同但又可与之匹配的功能。





模式匹配的相关函数


函数 string.find

这个我们在介绍string库的时候,已经说过了,功能就是查找子串在字符串中出现的开始位置和结束位置,但是我们当时介绍的比较简单,而string.find还支持更复杂的功能。

-- 查找第一个子串出现的位置
print(string.find("abc abc", "abc")) -- 1 3
-- 可以指定第三个参数,表示起始位置
-- 从第2个开始,那么就只能找第二个abc了
print(string.find("abc abc", "abc", 2)) -- 5 7
-- 除了第三个参数,还可以有第四个参数
-- 一个布尔值,表示是否进行简单搜索,默认是false
-- print(string.find("abc [abc", "[abc")),这行代码是会报错的
-- 因为[在lua中具有特殊意义,子串中不可以出现一个单独的[
-- 如果指定为true的话,表示单纯的字符串匹配,子串中的[就没有特殊含义了
print(string.find("abc [abc", "[abc", 0, true)) -- 5 8

函数 string.match

string.match和string.find类似,但是它返回的是一个匹配上的子串,而不是位置。

print(string.match("hello world", "world")) -- world
-- 虽然能匹配上,但是貌似没啥卵用
-- 别急,继续看
print(string.match("2020-01-01", "%d+-%d+-%d+")) -- 2020-01-01
-- 这样是不是就有用了呢?关于%d、以及string.match更高级的用法,我们后面会说

函数 string.gsub

这个函数我们也见过,接收三个参数:字符串、要替换的部分、替换成指定字符串,但是gsub还可以接收第4个参数,表示最多替换多少个。

print(string.gsub("aaa", ‘a‘, ‘b‘)) -- bbb 3
print(string.gsub("aaa", ‘a‘, ‘b‘, 2)) -- bba 2



模式


lua中也提供了其它语言中,类似于\d、\s之类的功能,但是lua中用的不是反斜杠,而是%,并且%(在模式匹配中)也是转义字符。



  • . 匹配 任意字符

  • %a 匹配 字母

  • %c 匹配 控制字符

  • %d 匹配 数字

  • %g 匹配 处空格之外的可打印字符

  • %l 匹配 小写字母

  • %u 匹配 大写字母

  • %p 匹配 标点符号

  • %s 匹配 空白字符

  • %w 匹配 字母和数字

  • %x 匹配 十六进制数字

除此之外,也提供了+、-、()、[]之类的具有特殊意义的字符,这个有兴趣可以自己查看,这里不说了。


lua中的日期和时间

lua中有两个函数,用于操作日期和时间,我们来看一下。

-- 调用os.time会返回当前时间的时间戳
print(os.time()) -- 1592124891
-- 也可以指定年月日时分秒
print(os.time({year=2020, mOnth=5, day=3, hour=14, min=33, sec=51})) -- 1588487631
-- 还有os.date
-- 自动打印当前的日期,除此之外还可以传入一个时间戳,来打印指定格式的日期
-- 返回的是一个string类型
print(os.date("%Y-%m-%d")) -- 2020-06-14
print(os.date("%Y-%m-%d", os.time({year=2020, mOnth=4, day=3}))) -- 2020-04-03

lua中关于日期的符号如下:

技术图片


编译、执行和错误

虽然我们把 Lua 语言称为解释型语言( interpreted language ),但 Lua 语言总是在运行代码前先预编译( precompile )源码为中间代码(这没什么大不了的,很多解释型语言也这样 做)。编译( compilation )阶段的存在听上去超出了解释型语言的范畴,但解释型语言的区分并不在于源码是否被编译,而在于是否有能力(且轻易地)执行动态生成的代码。

下面我们会详细学习lua语言运行代码的过程、编译究竟是什么意思和做了什 么、 Lua 语言是如何运行编译后代码的、以及在编译过程中如何处理错误。




编译


lua中提供了一个load函数,我们来看看是做什么的。

f = load("i = 1 + 1")
-- 当调用f的时候,load里面的字符串就会当成代码来执行
-- 有点类似于python中的exec
print(i) -- nil
f()
print(i) -- 2

尽管load的功能很强大,但还是要谨慎使用,因为相比其他的可选函数而言,load的开销比较大。另外,我们当前将load函数的返回值保存了下来,但是也可以不保存。

-- 直接调用
load("s = ‘这是字符串‘")()
print(s) -- 这是字符串
-- load里面还可以放入一个字符串形式的函数
-- 多行的话,我们可以通过[[ ]]来代替引号
load(
[[
function f()
return "hello mashiro"
end
]]
)()
print(f()) -- hello mashiro

如果load中的字符串不符合lua的语法规则,那么会得到什么呢?

print(load("s = ‘这是字符串‘")) -- function: 00000000001bd010
-- 我们看到如果符合语法规则,那么会返回一个函数
-- 不符合的话,会返回nil和错误信息
print(load("s === ‘这是字符串‘")) -- nil [string "s === ‘这是字符串‘"]:1: syntax error near ‘==‘
-- 因此最好的办法是使用assert函数
-- 如果里面的值为nil或者false,直接把异常抛出来
-- 比如assert(2 == 2)正常通过,assert(2!=2)就会报错,因为里面的值为false
-- 而如果为false或者nil,那么assert就会将抛出异常
-- 如果是返回了多个值,那么就看第一个值是否为nil或者false
print(load("s = ‘这是字符串‘")) -- function: 00000000001bd010
-- 我们看到如果符合语法规则,那么会返回一个函数
-- 不符合的话,会返回nil和错误信息
print(load("s === ‘这是字符串‘")) -- nil [string "s === ‘这是字符串‘"]:1: syntax error near ‘==‘
assert(load("s === ‘这是字符串‘"))
-- 返回值的第一个为nil,所以报错了,会把异常值抛出来
--[[
C:\lua\lua.exe: lua/5.lua:9: [string "s === ‘这是字符串‘"]:1: syntax error near ‘==‘
stack traceback:
[C]: in function ‘assert‘
lua/5.lua:9: in main chunk
[C]: in ?
]]

关于assert,可能有人不是很清楚,这里来说一下。

-- assert是用于断言的
assert(2 == 2)
print("正常通过")
-- 如果是assert(2 != 2),就通不过了
function f1()
return 123, nil
end
assert(f1())
-- 上述f1函数返回了两个值,而第一个值为真的,所以正常通过
function f2()
return nil, 123
end
-- 这里就通不过了,因为f2的返回值的第一个为nil
-- 如果报错了,那么会将返回值的第二个作为异常信息
assert(f2())
--[[
C:\lua\lua.exe: 123
stack traceback:
[C]: in function ‘assert‘
lua/5.lua:23: in main chunk
[C]: in ?
]]
-- assert一旦断言失败,那么程序就终止了,不会再往下走了

回到load函数,load函数里面如果是字符串,那么其实没有什么意义。f = load("i = 1")和function f() i = 1 end两者是完全等价的。

但是,由于后者、也就是定义函数的方式,代码会与其外层的函数一起被编译,所以其执行速度要快得多。与之对比,调用函数 load 时会进行一次独立编译

不过由于涉及到词法定界,上述示例的两段代码不一定完全等价


i = 123
function f1()
local i = 0
f = load("i = i + 1; print(i)")
g = function () i = i + 1;print(i) end
f()
g()
end
f1()
--[[
124
1
]]
print(i) -- 124
-- 我们看到全局中有一个i,函数f1中也有一个局部变量i
-- 对于load来说,会直接去找全局变量i,因为load总是在全局环境中编译代码段
-- 而内层函数在找i的时候,会现在自身中找、然后是外层函数、最后是全局变量
-- 所以f()执行打印124,此时外部的i也被修改了,内层函数由于找的是局部变量i,所以打印的是1

函数 load 最典型的用法是执行外部代码,比如我们可以在控制台中输入代码,因为收到的是字符串,所以正好可以通过load将其当成代码来执行。

但是这样能够处理的内容有限,所以在lua中还提供了一个loadfile,可以将一个文件里面的字符串当成代码来执行。

function add(a, b)
return a + b
end
a = 123
b = "xxx"

上面是一个文件1.txt,我们通过loadfile进行加载,加载的时候不需要文件名一定以lua结尾,只是将文件里面的内容读取出来,只要内容符合lua的语法规范即可。

loadfile("1.txt")()
print(add(11, 22)) -- 33
print(a) -- 123
print(b) -- xxx



预编译的代码


正如之前所说,lua在运行源代码之前会对源代码进行预编译,lua也可以允许我们手动编译源代码。

编译的方式可以通过luac进行编译,编译的文件已lc结尾。比如将5.lua编译成5.lc,就可以通过:"luac -o 5.lc 5.lua",编译之后lua解释器会像执行普通源代码一样执行编译之后的文件,完成与原来代码一致的动作。

可以自己尝试一下




错误


"人非圣贤孰能无过",因此我们必须尽可能的处理错误。并且由于lua是一种经常被嵌入到应用程序中的扩展语言,所以当错误发生时不能简单的崩溃或者退出。相反只要错误发生,lua必须提供处理错误的方式。

lua语言在遇到非预期情况时会引发错误,例如:将两个非数值类型的值相加,对不是函数的值进行调用,对不是表类型的值进行索引等等(当然我们后面会学习使用原表:metatable来改变上述行为)。我们也可以显示地调用函数error并传入一个错误信息作为参数来引发一个错误。通常,这个函数就是在代码中提示出错的合理方式

error("程序崩溃了")
--[[
C:\lua\lua.exe: lua/5.lua:1: 程序崩溃了
stack traceback:
[C]: in function ‘error‘
lua/5.lua:1: in main chunk
[C]: in ?
]]

由于针对某些情况调用函数error太常见了,所以lua还提供了内置函数assert来完成这类工作。assert不需要多说,会检测第一个参数是否为真,如果为真,那么正常通过;否则报错,同时会将第二个参数作为异常值。


assert总是在执行之前先对参数求值


assert(nil, "hello" .. " " .. "satori")
--[[
C:\lua\lua.exe: lua/5.lua:1: hello satori
stack traceback:
[C]: in function ‘assert‘
lua/5.lua:1: in main chunk
[C]: in ?
]]

这里是先将后面的字符串拼接起来之后,再执行assert,事实上,这算是废话,那个函数在执行前不是先确定参数的。

但是问题来了,我们的目的是希望在异常发生时,能够让其沉默,说人话就是异常捕获。

在lua中实现异常捕获的话,需要使用函数pcall,假设要执行一段lua代码并捕获里面出现的所有错误,那么需要首先将这段代码封装到一个函数中,然后通过pcall来调用这个函数。

function f()
a = "xxx"
return 1, 2, 3
end
print(pcall(f)) -- true 1 2 3
print(a) -- xxx
-- pcall里面需要传入一个函数,然后pcall来调用
-- 如果正确执行,那么pcall的返回值为:true 加上 pcall里面的函数的返回值
-- 如果执行失败,那么返回值为:false 加上 错误信息
-- 也可以直接传入一个匿名函数
print(pcall(function() print("" > 1) end)) -- false lua/5.lua:15: attempt to compare number with string
-- 如果报错,我们可以手动写入错误信息,通过函数error实现
status, err = pcall(function() error({code=666, err_msg="出错啦"}) end)
print(status, err.code, err.err_msg) -- false 666 出错啦

lua中异常捕获显然没有传统的try catch方便,但是对于一个小巧的脚本语言也足够了。




错误信息和栈回溯


-- 我们看到pcall里面传入的是一个函数名
-- 这就要求函数不能有参数,或者说pcall在调用的时候不会传递参数
-- 那么函数参数就全部为nil,因此如果函数需要参数该怎么办?
function f(arg)
if type(arg) ~= "string" then
error("需要一个字符串")
end
end
-- 答案是把调用放在一个函数里
pcall(function() f("xxx") end)
-- 另外,如果我们在这里传递的不是一个字符串的话
print(pcall(function()f(123) end)) -- false lua/5.lua:6: 需要一个字符串
-- 告诉我们需要一个字符串,但是报错提示在第6行,也就是函数f中
-- 但这明显不是f的问题,而是我们在匿名函数中值传递错误了
function f(arg)
if type(arg) ~= "string" then
-- 可以指定一个层级,告诉error实际发生错误的地方是第2层
-- 第一层为f本身
error("需要一个字符串", 2)
end
end
print(pcall(function()f(123) end)) -- false lua/5.lua:25: 需要一个字符串
-- 我们看到报错信息变成了25行,也就是上面这一行

模块和包

我们目前所有的代码都写在一个文件里面,但是内容多了之后,肯定不能都写在一个文件里面,而是需要分文件了。这个时候就要设计到导入模块,在lua中导入模块通过require?"模块名"即可。

-- 我们看到模块在lua中是一个table类型
print(type(math)) -- table
-- 我们调用math.sin相当于从math这张表里面检索sin
-- 在lua5.3中,模块是内嵌在解释器里面的
-- 但是我们也可以按照导入模块的模式进行加载
m = require "math"
-- 我们看到得到的结果是一样
print(type(m), m.sin(m.pi / 2)) -- table 1.0
-- 模块不会重复导入,所以m和math是同一个表
print(m == math) -- true



自己编写一个模块


-- m.lua
-- 创建一个模块,我们说模块本质上一张表
m = {}
function add(a, b)
return a + b
end
-- 将add函数加入到模块m中
-- 当然也可以是m.xxx = add, 只不过我们后面在导入这个模块的时候就不能调用m.add了,而是m.xxx
m.add = add
-- 或者
function m.sub(a, b)
return a - b
end
-- 对于变量也是可以ide
m.a = 123
b = 456
m.b = b
-- 最后将模块返回出去
return m

导入模块

-- 注意:如果直接去找的话,是找不到的,因为只会到默认的路径中去找
-- python的话会把当前模块所在的路径也算进去,但是lua不会
-- 我们需要使用package.path增加搜索路径, 多个搜索路径的话使用;分隔即可
package.path = "D:\\satori\\lua\\?.lua"
m = require "m"
print(m.add(1, 2)) -- 3
print(m.sub(1, 2)) -- -1
print(m.a) -- 123
print(m.b) -- 456

lua语言(2):闭包、模式匹配、日期、编译、模块



推荐阅读
  • 本文探讨了使用C#在SQL Server和Access数据库中批量插入多条数据的性能差异。通过具体代码示例,详细分析了两种数据库的执行效率,并提供了优化建议。 ... [详细]
  • 本问题探讨了在特定条件下排列儿童队伍的方法数量。题目要求计算满足条件的队伍排列总数,并使用递推算法和大数处理技术来解决这一问题。 ... [详细]
  • 深入理解Lucene搜索机制
    本文旨在帮助读者全面掌握Lucene搜索的编写步骤、核心API及其应用。通过详细解析Lucene的基本查询和查询解析器的使用方法,结合架构图和代码示例,带领读者深入了解Lucene搜索的工作流程。 ... [详细]
  • 哈密顿回路问题旨在寻找一个简单回路,该回路包含图中的每个顶点。本文将介绍如何判断给定的路径是否构成哈密顿回路。 ... [详细]
  • 算法题解析:最短无序连续子数组
    本题探讨如何通过单调栈的方法,找到一个数组中最短的需要排序的连续子数组。通过正向和反向遍历,分别使用单调递增栈和单调递减栈来确定边界索引,从而定位出最小的无序子数组。 ... [详细]
  • 本文介绍如何在 C++ 中使用链表结构存储和管理数据。通过具体示例,展示了静态链表的基本操作,包括节点的创建、链接及遍历。 ... [详细]
  • 反向投影技术主要用于在大型输入图像中定位特定的小型模板图像。通过直方图对比,它能够识别出最匹配的区域或点,从而确定模板图像在输入图像中的位置。 ... [详细]
  • 本文详细探讨了JavaScript中的作用域链和闭包机制,解释了它们的工作原理及其在实际编程中的应用。通过具体的代码示例,帮助读者更好地理解和掌握这些概念。 ... [详细]
  • 本文探讨了在使用Selenium进行自动化测试时,由于webdriver对象实例化位置不同而导致浏览器闪退的问题,并提供了详细的代码示例和解决方案。 ... [详细]
  • 解决Anaconda安装TensorFlow时遇到的TensorBoard版本问题
    本文介绍了在使用Anaconda安装TensorFlow时遇到的“Could not find a version that satisfies the requirement tensorboard”错误,并提供详细的解决方案,包括创建虚拟环境和配置PyCharm项目。 ... [详细]
  • 查找最小值的操作是很简单的,只需要从根节点递归的遍历到左子树节点即可。当遍历到节点的左孩子为NULL时,则这个节点就是树的最小值。上面的树中,从根节点20开始,递归遍历左子 ... [详细]
  • 在使用STM32Cube进行定时器配置时,有时会遇到延时不准的问题。本文探讨了可能导致延时不准确的原因,并提供了解决方法和预防措施。 ... [详细]
  • 在项目部署后,Node.js 进程可能会遇到不可预见的错误并崩溃。为了及时通知开发人员进行问题排查,我们可以利用 nodemailer 插件来发送邮件提醒。本文将详细介绍如何配置和使用 nodemailer 实现这一功能。 ... [详细]
  • Ihaveastringwithquotesaroundthepathasfollows:我在路径周围有一个带引号的字符串,如下所示:C:\ProgramFiles(x ... [详细]
  • Windows 7 64位系统下Redis的安装与PHP Redis扩展配置
    本文详细介绍了在Windows 7 64位操作系统中安装Redis以及配置PHP Redis扩展的方法,包括下载、安装和基本使用步骤。适合对Redis和PHP集成感兴趣的开发人员参考。 ... [详细]
author-avatar
逗趣游戏
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有