GNU Make 项目管理 附录 B 外部限制

创建时间 2021-04-25
更新时间 2021-04-26

正如您已经看到的,GNU make 可以做一些非常不可思议的事情,但是我还没有看到太多用 eval 构造真正突破 make 3.80 极限的东西。在这个练习中,我们将看看是否能比平时走的更远。

数据结构

在编写复杂的 makefile 时,make的一个局限性是它缺乏数据结构。在一种非常有限的方式中,您可以通过定义带有内嵌周期(甚至 -> 如果你能忍受的话):

file.path = /foo/bar
file.type = unix
file.host = oscar

如果按下,你甚至可以通过使用计算变量把这个文件结构“传递”给一个函数:

define remote-file
    $(if $(filter unix,$($1.type)), \
        /net/$($1.host)/$($1.path), \
        //$($1.host)/$($1.path))
endef

然而,这似乎不是一个令人满意的解决方案,原因如下:

  • 你不能轻易分配这个“结构”的实例。创建新实例包括选择新变量名并为每个元素赋值。这也意味着不能保证这些伪实例具有相同的字段(称为插槽)。

  • 结构只存在于用户的头脑中,作为一组不同的 make 变量,而不是作为一个具有自己名字的统一实体。而且由于结构没有名称,很难创建到结构的引用(或指针),因此将它们作为参数传递或存储在变量中是笨拙的。

  • 没有安全的方式进入结构的插槽。变量名的任何一部分中的任何印刷错误都会产生错误的值(或没有值),make 不会发出警告。

但是远程文件功能暗示了一个更全面的解决方案。假设我们使用计算变量实现结构实例。早期的Lisp对象系统(甚至今天的一些)使用了类似的技术。一个结构,比如 file-info,可以有用符号名表示的实例,比如 file_info_1

另一个实例可能被称为 file_info_2。该结构的槽可以用计算变量表示:

file_info_1_path
file_info_1_type
file_info_1_host

由于实例有一个符号名,它可以保存在一个或多个变量中(通常,使用递归或简单变量是程序员的选择):

before_foo = file_info_1
another_foo = $(before_foo)

file-info 的元素可以使用类似 lispgettersetter 来访问:

path := $(call get-value,before_foo,path)
$(call set-value,before_foo,path,/usr/tmp/bar)

我们可以更进一步,为 file-info 结构创建一个模板来方便地分配新实例:

orig_foo := $(call new,file-info)
$(call set-value,orig_foo,path,/foo/bar)

tmp_foo := $(call new,file-info)
$(call set-value,tmp_foo,path,/tmp/bar)

现在,存在两个不同的 <literal>file-info</literal> 实例。最后一个方便之处是,我们可以为插槽添加默认值的概念。要声明 file-info 结构,可以使用:

$(call defstruct,file-info,         \
    $(call defslot,path,),          \
        $(call defslot,type,unix),  \
            $(call defslot,host,oscar))

这里,defstruct 函数的第一个参数是结构名,后面是 defslot 调用的列表。每个 defslot 包含一个(名称,默认值)对。

示例 B-1 显示了 defstruct 的实现及其支持代码。

示例 B-1 make 中的结构定义

# $(next-id) - return a unique number
next_id_counter :=
define next-id
    $(words $(next_id_counter))$(eval next_id_counter += 1)
endef

# all_structs - a list of the defined structure names
all_structs :=

value_sep := XxSepxX

# $(call defstruct, struct_name, $(call defslot, slot_name, value), ...)
define defstruct
    $(eval all_structs += $1)                           \
    $(eval $1_def_slotnames :=)                         \
    $(foreach v, $2 $3 $4 $5 $6 $7 $8 $9 $(10) $(11),   \
        $(if $($v_name),                                \
            $(eval $1_def_slotnames += $($v_name))      \
            $(eval $1_def_$($v_name)_default := $($v_value))))
endef

# $(call defslot,slot_name,slot_value)
define defslot
    $(eval tmp_id := $(next_id))
    $(eval $1_$(tmp_id)_name := $1)
    $(eval $1_$(tmp_id)_value := $2)
    $1_$(tmp_id)
endef

# all_instances - a list of all the instances of any structure
all_instances :=

# $(call new, struct_name)
define new
$(strip                                                             \
    $(if $(filter $1,$(all_structs)),,                              \
        $(error new on unknown struct '$(strip $1)'))               \
    $(eval instance := $1@$(next-id))                               \
    $(eval all_instances += $(instance))                            \
    $(foreach v, $($(strip $1)_def_slotnames),                      \
        $(eval $(instance)_$v := $($(strip $1)_def_$v_default)))    \
    $(instance))
endef

# $(call delete, variable)
define delete
$(strip                                                                     \
    $(if $(filter $($(strip $1)),$(all_instances)),,                        \
        $(error Invalid instance '$($(strip $1))'))                         \
    $(eval all_instances := $(filter-out $($(strip $1)),$(all_instances)))  \
    $(foreach v, $($(strip $1)_def_slotnames),                              \
        $(eval $(instance)_$v := )))
endef

# $(call struct-name, instance_id)
define struct-name
    $(firstword $(subst @, ,$($(strip $1))))
endef

# $(call check-params, instance_id, slot_name)
define check-params
    $(if $(filter $($(strip $1)),$(all_instances)),,            \
        $(error Invalid instance '$(strip $1)'))                \
    $(if $(filter $2,$($(call struct-name,$1)_def_slotnames)),, \
        $(error Instance '$($(strip $1))' does not have slot '$(strip $2)'))
endef

# $(call get-value, instance_id, slot_name)
define get-value
$(strip                         \
    $(call check-params,$1,$2)  \
    $($($(strip $1))_$(strip $2)))
endef

# $(call set-value, instance_id, slot_name, value)
define set-value
    $(call check-params,$1,$2) \
    $(eval $($(strip $1))_$(strip $2) := $3)
endef

# $(call dump-struct, struct_name)
define dump-struct
{ $(strip $1)_def_slotnames "$($(strip $1)_def_slotnames)"  \
    $(foreach s,                                            \
        $($(strip $1)_def_slotnames),$(strip                \
        $(strip $1)_def_$s_default "$($(strip $1)_def_$s_default)")) }
endef

# $(call print-struct, struct_name)
define print-struct
{ $(foreach s,                              \
    $($(strip $1)_def_slotnames),$(strip    \
    { "$s" "$($(strip $1)_def_$s_default)" })) }
endef

# $(call dump-instance, instance_id)
define dump-instance
{ $(eval tmp_name := $(call struct-name,$1))    \
    $(foreach s,                                \
        $($(tmp_name)_def_slotnames),$(strip    \
        { $($(strip $1))_$s "$($($(strip $1))_$s)" })) }
endef

# $(call print-instance, instance_id)
define print-instance
    { $(foreach s,                                          \
        $($(call struct-name,$1)_def_slotnames),"$(strip    \
        $(call get-value,$1,$s))") }
endef

逐个检查这段代码,可以看到它首先定义了函数 next-id。这是一个简单的计数器:

# $(next-id) - return a unique number
next_id_counter :=

define next-id
    $(words $(next_id_counter))$(eval next_id_counter += 1)
endef

人们常说,你不能在 make 中做算术,因为语言的局限性太大。一般来说,这是正确的,但对于像这样的有限情况,你通常可以计算出你需要的。这个函数使用 eval 重定义一个简单变量的值。该函数包含两个表达式:第一个表达式返回 next_id_counter 中的单词数;第二个表达式将另一个单词追加到变量。它不是很有效,但对于小的几千个数字,它是好的。下一节将定义 defstruct 函数本身并创建支持的数据结构。

# all_structs - a list of the defined structure names
all_structs :=

value_sep := XxSepxX

# $(call defstruct, struct_name, $(call defslot, slot_name, value), ...)
define defstruct
    $(eval all_structs += $1)                           \
    $(eval $1_def_slotnames :=)                         \
    $(foreach v, $2 $3 $4 $5 $6 $7 $8 $9 $(10) $(11),   \
        $(if $($v_name),                                \
            $(eval $1_def_slotnames += $($v_name))      \
            $(eval $1_def_$($v_name)_default := $($v_value))))
endef

# $(call defslot,slot_name,slot_value)
define defslot
    $(eval tmp_id := $(next_id))
    $(eval $1_$(tmp_id)_name := $1)
    $(eval $1_$(tmp_id)_value := $2)
    $1_$(tmp_id)
endef

变量 all_structs 是用 defstruct 定义的所有已知结构的列表。这个列表允许新函数对它分配的结构执行类型检查。对于每个结构 S, defstruct 函数定义了一组变量:

S_def_slotnames
S_def_slotn_default

第一个变量为结构定义了一组槽。其他变量定义每个插槽的默认值。defstruct 函数的前两行分别附加到 all_structs 并初始化槽名列表。函数的其余部分遍历槽,构建槽列表并保存默认值。

每个插槽定义都由 defslot 处理。该函数分配一个 id,将槽位名称和值保存在两个变量中,并返回前缀。返回前缀允许defstruct 的参数列表成为一个简单的符号列表,每个符号都提供对槽定义的访问。如果以后在插槽中添加了更多的属性,那么将它们合并到 defslot 中就很简单了。这种技术还允许默认值具有比简单的替代实现更广泛的值范围(包括空格)。

defstruct 中的 foreach 循环确定了允许的最大插槽数。这个版本允许10个插槽。foreach 的主体通过将槽名附加到 S_def_slotnames 并将默认值分配给变量来处理每个参数。例如,我们的 file-info 结构将定义:

file-info_def_slotnames := path type host
file-info_def_path_default :=
file-info_def_type_default := unix
file-info_def_host_default := oscar

这就完成了结构的定义。

现在我们可以定义结构了,我们需要能够实例化一个。新函数执行如下操作:

# $(call new, struct_name)
define new
    $(strip                                                             \
        $(if $(filter $1,$(all_structs)),,                              \
            $(error new on unknown struct '$(strip $1)'))               \
        $(eval instance := $1@$(next-id))                               \
        $(eval all_instances += $(instance))                            \
        $(foreach v, $($(strip $1)_def_slotnames),                      \
            $(eval $(instance)_$v := $($(strip $1)_def_$v_default)))    \
        $(instance))
endef

函数中的第一个 if 检查名称是否引用了已知结构。如果在 all_structs 中没有找到该结构,则会发出错误信号。接下来,我们将结构名与唯一的整数后缀连接起来,从而为新实例构造一个唯一的 id。我们使用 @ 符号将结构名与后缀分隔开来,这样以后就可以很容易地将两者分隔开来。然后,新函数记录新的实例名,以便稍后由访问器进行类型检查。然后,结构中的槽用它们的默认值初始化。初始化代码很有趣:

$(foreach v, $($(strip $1)_def_slotnames), \
    $(eval $(instance)_$v := $($(strip $1)_def_$v_default)))

foreach 循环遍历结构的槽名。在结构名上使用 strip 可以让用户在调用new时在逗号之后添加空格。回想一下,每个槽都是通过连接实例名和槽名来表示的(例如,file_info@1_path)。右边是根据结构名和槽名计算的默认值。最后,函数返回实例名。

注意,我将这些结构称为函数,但它们实际上是宏。也就是说,递归地展开 new 符号以生成插入 makefile 以便重新解析的新文本。解构宏执行我们想要的操作的原因是,所有的工作最终都嵌入到 eval 调用中,这些调用会压缩为零。类似地,新宏在 eval 调用中执行其重要工作。它可以被合理地称为一个函数,因为宏的展开从概念上产生一个值,即表示新实例的符号。接下来,我们需要能够在结构中获取和设置值。为了做到这一点,我们定义两个新函数:

# $(call get-value, instance_id, slot_name)
define get-value
    $(strip                         \
        $(call check-params,$1,$2)  \
        $($($(strip $1))_$(strip $2)))
endef

# $(call set-value, instance_id, slot_name, value)
define set-value
    $(call check-params,$1,$2) \
    $(eval $($(strip $1))_$(strip $2) := $3)
endef

要获得槽的值,我们只需要从实例 id 和槽名计算槽变量名。我们可以通过先用 check-params 函数检查实例和槽名是否为有效字符串来提高安全性。为了允许更美观的格式,并确保无关的空间不会破坏槽值,我们将大多数这些参数封装在 strip 调用中。

set 函数还在设置值之前检查参数。同样,我们删除了两个函数参数,以允许用户在参数列表中添加空格。注意,我们没有删除槽值,因为用户可能实际需要这些空格。

# $(call check-params, instance_id, slot_name)
define check-params
    $(if $(filter $($(strip $1)),$(all_instances)),, \
        $(error Invalid instance '$(strip $1)')) \
    $(if $(filter $2,$($(call struct-name,$1)_def_slotnames)),, \
        $(error Instance '$($(strip $1))' does not have slot '$(strip $2)'))
endef

# $(call struct-name, instance_id)
define struct-name
    $(firstword $(subst @, ,$($(strip $1))))
endef

check-params 函数只是检查传递给 settergetter 函数的实例 id 是否包含在已知实例列表中。同样,它检查槽名是否包含在属于该结构的槽的列表中。结构名是通过分割 @ 上的符号并取第一个单词来从实例名计算的。这意味着结构名称不能包含 @ 符号。

为了使实现更加完善,我们可以添加两个打印和调试函数。下面的print函数显示结构定义和结构实例的简单用户可读表示,而 dump 函数显示这两个构造的实现细节。具体实现请参见示例 B-1。

下面是定义和使用 file-info 结构的例子:

include defstruct.mk
$(call defstruct, file-info,    \
    $(call defslot, path,),     \
    $(call defslot, type,unix), \
    $(call defslot, host,oscar))

before := $(call new, file-info)
$(call set-value, before, path,/etc/password)
$(call set-value, before, host,wasatch)

after := $(call new,file-info)
$(call set-value, after, path,/etc/shadow)
$(call set-value, after, host,wasatch)

样例:

    # before = $(before)
    # before.path = $(call get-value, before, path)
    # before.type = $(call get-value, before, type)
    # before.host = $(call get-value, before, host)
    # print before = $(call print-instance, before)
    # dump before = $(call dump-instance, before)
    #
    # all_instances = $(all_instances)
    # all_structs = $(all_structs)
    # print file-info = $(call print-struct, file-info)
    # dump file-info = $(call dump-struct, file-info)

和输出:

$ make
# before = file-info@0
# before.path = /etc/password
# before.type = unix
# before.host = wasatch
# print before = { "/etc/password" "unix" "wasatch" }
# dump before = { { file-info@0_path "/etc/password" } { file-info@0_type "unix" }
{ file-info@0_host "wasatch" } }
#
# all_instances = file-info@0 file-info@1
# all_structs = file-info
# print file-info = { { "path" "" } { "type" "unix" } { "host" "oscar" } }
# dump file-info = { file-info_def_slotnames " path type host" file-info_def_path_
default "" file-info_def_type_default "unix" file-info_def_host_default "oscar" }

还要注意非法结构的使用是如何被困住的:

$ cat badstruct.mk
include defstruct.mk
$(call new, no-such-structure)
$ make -f badstruct.mk
badstruct.mk:2: *** new on unknown struct 'no-such-structure'. Stop.
$ cat badslot.mk
include defstruct.mk
$(call defstruct, foo, defslot(size, 0))
bar := $(call new, foo)
$(call set-value, bar, siz, 10)
$ make -f badslot.mk
badslot.mk:4: *** Instance 'foo@0' does not have slot 'siz'. Stop.

当然,可以对代码进行很多改进,但基本的思想是合理的。以下是可能的增强:

  • 为插槽分配添加一个验证检查。这可以用钩子函数来完成,钩子函数必须在赋值完成后为空。钩子可以这样使用:
# $(call set-value, instance_id, slot_name, value)
define set-value
    $(call check-params,$1,$2)                                          \
    $(if $(call $(strip $1)_$(strip $2)_hook, value),                   \
        $(error set-value hook, $(strip $1)_$(strip $2)_hook, failed))  \
    $(eval $($(strip $1))_$(strip $2) := $3)
endef
  • 支持继承。一个 defstruct 可以接受另一个 defstruct 名称作为父类,复制父类在子类中的所有成员。

  • 更好地支持结构引用。在当前的实现中,一个槽可以保存另一个结构的ID,但是访问很尴尬。可以编写一个新版本的 get-value 函数来检查引用(通过查找 defstruct@number),并执行自动解引用。

算术运算

在前一节中,我注意到仅使用 make 的固有特性是不可能在 make 中执行算术的。然后,我展示了如何通过向列表添加单词并返回列表的长度来实现一个简单的计数器。在我发现递增技巧后不久,Michael Mounteney 发布了一个很酷的技巧,用于在 make 中对整数执行有限形式的加法。

他的技巧是通过数轴计算大小为1或更大的两个整数的和。想知道这是怎么回事,想象一下数轴:

2 3 4 5 6 7 8 9 10 11 12 13 14 15

现在,注意(如果我们能把下标写对),我们可以加上,比方说4加5,首先取第4个元素到最后的那一行的一个子集然后选择子集的第5个元素。我们可以使用本机的make函数来做到这一点:

number_line = 2 3 4 5 6 7 8 9 10 11 12 13 14 15
plus = $(word $2, $(wordlist $1, 15, $(number_line)))
four+five = $(call plus, 4, 5)

非常聪明, 迈克尔,注意数以 2 开始,而不是 0 或 1。你可以看到这是必要的如果你运行 1 和 1 的 plus 函数。两个下标都将产生第一个元素,并且答案必须是 2,因此,列表的第一个元素必须是 2。这样做的原因是,对于 wordwordlist 函数,列表的第一个元素的下标是1而不是0(但我没有费心去证明它)。

现在,给定一行数,我们可以执行加法,但是我们如何在 make 中创建一行数而不需要手工输入或使用 shell 程序?我们可以通过将十位上的所有可能值和个位上的所有可能值结合起来来创建 00 到 99 之间的所有数字。例如:

make -f - <<< '$(warning $(foreach i, 0 1 2, $(addprefix $i, 0 1 2)))'
/c/TEMP/Gm002568:1: 00 01 02 10 11 12 20 21 22

通过包含从 0 到 9 的所有数字,我们将产生从 00 到 99 的所有数字。通过将 foreach 再次与数百列组合,我们将得到从 000 到 999 的数字,等等。剩下的就是在必要的时候去掉前面的零。

下面是 Mr. Mounteney 代码的改进形式,用于生成数列并定义加号和大于运算:

# combine - concatentate one sequence of numbers with another
combine = $(foreach i, $1, $(addprefix $i, $2))

# stripzero - Remove one leading zero from each word
stripzero = $(patsubst 0%,%,$1)

# generate - Produce all permutations of three elements from the word list
generate = $(call stripzero,        \
            $(call stripzero,       \
                $(call combine, $1, \
                    $(call combine, $1, $1))))

# number_line - Create a number line from 0 to 999
number_line := $(call generate,0 1 2 3 4 5 6 7 8 9)
length := $(word $(words $(number_line)), $(number_line))

# plus - Use the number line to add two integers
plus = $(word $2,                   \
        $(wordlist $1, $(length),   \
            $(wordlist 3, $(length), $(number_line))))

# gt - Use the number line to determine if $1 is greater than $2
gt = $(filter $1,               \
    $(wordlist 3, $(length),    \
    $(wordlist $2, $(length), $(number_line))))

all:
    @echo $(call plus,4,7)
    @echo $(if $(call gt,4,7),is,is not)
    @echo $(if $(call gt,7,4),is,is not)
    @echo $(if $(call gt,7,7),is,is not)

当运行时,makefile 产生:

$ make
11
is not
is
is not

我们可以扩展这段代码,使之包含减法,只要注意对反向列表进行下标就像向后计数一样。例如,要计算7 - 4,首先创建序号子集0到6,将其倒过来,然后选择第4个元素:

number_line := 0 1 2 3 4 5 6 7 8 9...
1through6 := 0 1 2 3 4 5 6
reverse_it := 6 5 4 3 2 1 0
fourth_item := 3

下面是 make 语法中的算法:

# backwards - a reverse number line
backwards := $(call generate, 9 8 7 6 5 4 3 2 1 0)

# reverse - reverse a list of words
reverse = $(strip                               \
    $(foreach f,                                \
        $(wordlist 1, $(length), $(backwards)), \
            $(word $f, $1)))

# minus - compute $1 minus $2
minus = $(word $2,      \
        $(call reverse, \
        $(wordlist 1, $1, $(number_line))))

minus:
    # $(call minus, 7, 4)

乘法和除法留作读者练习。

参考资料