为什么在代码块管道内部时延迟扩展会失败?

42

这是一个简单的批处理文件,演示了如果延迟扩展在被管道控制的块内部,它将无法正常工作(故障发生在脚本末尾)。有谁能解释一下这是为什么?

我有一个解决方法,但需要创建临时文件。我最初在在Windows批处理文件中查找文件并按大小排序时遇到了这个问题。

@echo off
setlocal enableDelayedExpansion

set test1=x
set test2=y
set test3=z

echo(

echo NORMAL EXPANSION TEST
echo Unsorted works
(
  echo %test3%
  echo %test1%
  echo %test2%
)
echo(
echo Sorted works
(
  echo %test3%
  echo %test1%
  echo %test2%
) | sort

echo(
echo ---------
echo(

echo DELAYED EXPANSION TEST
echo Unsorted works
(
  echo !test3!
  echo !test1!
  echo !test2!
)
echo(
echo Sorted fails
(
  echo !test3!
  echo !test1!
  echo !test2!
) | sort
echo(
echo Sort workaround
(
  echo !test3!
  echo !test1!
  echo !test2!
)>temp.txt
sort temp.txt
del temp.txt

以下是结果

NORMAL EXPANSION TEST
Unsorted works
z
x
y

Sorted works
x
y
z

---------

DELAYED EXPANSION TEST
Unsorted works
z
x
y

Sorted fails
!test1!
!test2!
!test3!

Sort workaround
x
y
z
3个回答

52

正如Aacini所示,似乎许多事情都会在管道中失败。

echo hello | set /p var=
echo here | call :function

但实际上问题只在于理解管道的工作原理。

每个管道的一侧都会在自己的异步线程中启动cmd.exe。
这就是为什么许多东西似乎出了问题的原因。

但有了这个知识,你可以避免这种情况并创建新的效果。

echo one | ( set /p varX= & set varX )
set var1=var2
set var2=content of two
echo one | ( echo %%%var1%%% )
echo three | echo MYCMDLINE %%cmdcmdline%%
echo four  | (cmd /v:on /c  echo 4: !var2!)
< p>更新于2019年8月15日:
正如在为什么使用变量扩展的`findstr`在搜索字符串中会返回意外结果时涉及管道?中所发现的那样,只有当命令是cmd.exe内部命令、命令是批处理文件或命令被括在圆括号块中时,才会使用cmd.exe。不在括号内的外部命令将在没有cmd.exe帮助下启动新进程。

编辑:深度分析

dbenham所示,管道两侧的扩展阶段是等效的。
主要规则似乎是:

完成常规批处理解析器阶段
..百分数扩展
..特殊字符阶段/块开始检测
..延迟扩展(但仅在启用延迟扩展且不是命令块时)

使用C:\Windows\system32\cmd.exe /S /D /c"<BATCH COMMAND>"启动cmd.exe
这些扩展遵循cmd行解析器的规则,而不是批处理行解析器的规则。

..百分数扩展
..延迟扩展(但仅在启用延迟扩展时)

如果<BATCH COMMAND>位于括号块中,则将对其进行修改。

(
echo one %%cmdcmdline%%
echo two
) | more

被称为C:\Windows\system32\cmd.exe /S /D /c" ( echo one %cmdcmdline% & echo two )",所有换行符都被改成了&运算符。

为什么括号会影响延迟扩展阶段?
我认为这是因为在批处理解析器阶段无法进行扩展,因为一个块可以包含多个命令,并且当执行一条线时才会生效延迟扩展。

(
set var=one
echo !var!
set var=two
) | more

显然在批处理上下文中无法评估 !var!,因为这些行仅在cmd-line上下文中执行。

但是为什么在此情况下它可以在批处理上下文中进行评估?

echo !var! | more

在我看来,这是一个“bug”或不一致的行为,但这并不是第一个。

编辑:添加LF trick

正如dbenham展示的那样,在cmd行为中似乎存在一些限制,它将所有换行符更改为&

(
  echo 7: part1
  rem This kills the entire block because the closing ) is remarked!
  echo part2
) | more

这将导致以下结果:
C:\Windows\system32\cmd.exe /S /D /c" ( echo 7: part1 & rem This ...& echo part2 ) "
rem 命令将注释整个行的结尾,因此即使缺少结束括号,也会注释整个行。

但是你可以通过嵌入自己的换行符来解决这个问题!

set LF=^


REM The two empty lines above are required
(
  echo 8: part1
  rem This works as it splits the commands %%LF%% echo part2  
) | more

这将导致 C:\Windows\system32\cmd.exe /S /D /c" ( echo 8: part1 %cmdcmdline% & rem This works as it splits the commands %LF% echo part2 )"

而由于在解析括号时会扩展%LF%,因此结果代码看起来像是

( echo 8: part1 & rem This works as it splits the commands 
  echo part2  )

在括号内,即使是批处理文件中,%LF% 行为总是有效的。
但是在“普通”行中,单个 <linefeed> 将停止解析该行。

编辑:异步不完全是真相

我说过两个线程都是异步的,通常情况下这是正确的。
但实际上,在未被右线程使用时,左线程可能会锁定自己的管道数据。
似乎在“管道”缓冲区中有一个约为 1000 字符的限制,然后线程将被阻塞,直到数据被使用。

@echo off
(
    (
    for /L %%a in ( 1,1,60 ) DO (
            echo A long text can lock this thread
            echo Thread1 ##### %%a > con
        )
    )
    echo Thread1 ##### end > con
) | (
    for /L %%n in ( 1,1,6 ) DO @(
        ping -n 2 localhost > nul
        echo Thread2 ..... %%n
        set /p x=
    )
)

1
+1 非常有趣!这种行为修改了一些基本的批处理规则:即使没有通过 CALLCMD /C 调用 BatSub.batcd . | BatSubBatSub.bat 结束后也会返回到当前批处理文件(我们现在知道这里有一个隐式的 CMD /C)。此外,我们现在知道使用两个重定向 com1 > file & com2 < file 比使用管道 com1 | com2 更快;从现在开始我将避免使用管道而选择使用两个重定向。所有这些听起来对我来说都很奇怪! @jeb:只有一个细节,管道右侧的执行不是异步的... - Aacini
2
@jeb:你说得对!管道中两边的执行确实是异步的!请参见我的答案补充(这变得越来越奇怪了……) - Aacini
1
很棒的内容,Jeb。现在确实一切都有意义了。%%cmcmdline%%技巧真的有助于解释事情。还有一件事尚未提到:CMD.EXE不会继承父进程的延迟扩展状态;它基于注册表设置默认此状态。假定命令扩展状态也是如此。 - dbenham
1
关于rem注释整行尾部的问题:当您使用rem/而不是仅使用rem时,管道代码可以正常工作,前提是备注中不包含)或者像^^^)这样的双重转义符号。 - aschipfl
1
@aschipfl 是的,这是比 LF 技巧更好的替代方案。它能够工作,因为 rem/ 没有被第二阶段中特殊的 REM-Parser 检测到,所以它被处理成一个普通命令,只有在执行阶段解析器才会检测到它只是一个 rem 并且可以被忽略。 - jeb
显示剩余6条评论

11

我不确定是编辑我的问题还是将其发布为答案。

我已经模糊地了解到,管道会在各自的CMD.EXE“会话”中执行左边和右边的命令。但是Aacini和jeb的回答迫使我真正思考并调查了管道的运作方式。(感谢jeb演示了管道转到SET / P时会发生什么!)

我编写了这个调查脚本-它有助于解释很多内容,但也展示了一些奇怪和意想不到的行为。我将发布脚本及其输出。最后我会提供一些分析。

@echo off
cls
setlocal disableDelayedExpansion
set var1=value1
set "var2="
setlocal enableDelayedExpansion

echo on
@echo NO PIPE - delayed expansion is ON
echo 1: %var1%, %var2%, !var1!, !var2!
(echo 2: %var1%, %var2%, !var1!, !var2!)

@echo(
@echo PIPE LEFT SIDE - Delayed expansion is ON
echo 1L: %%var1%%, %%var2%%, !var1!, !var2! | more
(echo 2L: %%var1%%, %%var2%%, !var1!, !var2!) | more
(setlocal enableDelayedExpansion & echo 3L: %%var1%%, %%var2%%, !var1!, !var2!) | more
(cmd /v:on /c echo 4L: %%var1%%, %%var2%%, !var1!, !var2!) | more
cmd /v:on /c echo 5L: %%var1%%, %%var2%%, !var1!, !var2! | more
@endlocal
@echo(
@echo Delayed expansion is now OFF
(cmd /v:on /c echo 6L: %%var1%%, %%var2%%, !var1!, !var2!) | more
cmd /v:on /c echo 7L: %%var1%%, %%var2%%, !var1!, !var2! | more

@setlocal enableDelayedExpansion
@echo(
@echo PIPE RIGHT SIDE - delayed expansion is ON
echo junk | echo 1R: %%var1%%, %%var2%%, !var1!, !var2!
echo junk | (echo 2R: %%var1%%, %%var2%%, !var1!, !var2!)
echo junk | (setlocal enableDelayedExpansion & echo 3R: %%var1%%, %%var2%%, !var1!, !var2!)
echo junk | (cmd /v:on /c echo 4R: %%var1%%, %%var2%%, !var1!, !var2!)
echo junk | cmd /v:on /c echo 5R: %%var1%%, %%var2%%, !var1!, !var2!
@endlocal
@echo(
@echo Delayed expansion is now OFF
echo junk | (cmd /v:on /c echo 6R: %%var1%%, %%var2%%, !var1!, !var2!)
echo junk | cmd /v:on /c echo 7R: %%var1%%, %%var2%%, !var1!, !var2!


这是输出结果

NO PIPE - delayed expansion is ON

C:\test>echo 1: value1, , !var1!, !var2!
1: value1, , value1,

C:\test>(echo 2: value1, , !var1!, !var2! )
2: value1, , value1,

PIPE LEFT SIDE - Delayed expansion is ON

C:\test>echo 1L: %var1%, %var2%, !var1!, !var2!   | more
1L: value1, %var2%, value1,


C:\test>(echo 2L: %var1%, %var2%, !var1!, !var2! )  | more
2L: value1, %var2%, !var1!, !var2!


C:\test>(setlocal enableDelayedExpansion   & echo 3L: %var1%, %var2%, !var1!, !var2! )  | more
3L: value1, %var2%, !var1!, !var2!


C:\test>(cmd /v:on /c echo 4L: %var1%, %var2%, !var1!, !var2! )  | more
4L: value1, %var2%, value1, !var2!


C:\test>cmd /v:on /c echo 5L: %var1%, %var2%, !var1!, !var2!   | more
5L: value1, %var2%, value1,


Delayed expansion is now OFF

C:\test>(cmd /v:on /c echo 6L: %var1%, %var2%, !var1!, !var2! )  | more
6L: value1, %var2%, value1, !var2!


C:\test>cmd /v:on /c echo 7L: %var1%, %var2%, !var1!, !var2!   | more
7L: value1, %var2%, value1, !var2!


PIPE RIGHT SIDE - delayed expansion is ON

C:\test>echo junk   | echo 1R: %var1%, %var2%, !var1!, !var2!
1R: value1, %var2%, value1,

C:\test>echo junk   | (echo 2R: %var1%, %var2%, !var1!, !var2! )
2R: value1, %var2%, !var1!, !var2!

C:\test>echo junk   | (setlocal enableDelayedExpansion   & echo 3R: %var1%, %var2%, !var1!, !var2! )
3R: value1, %var2%, !var1!, !var2!

C:\test>echo junk   | (cmd /v:on /c echo 4R: %var1%, %var2%, !var1!, !var2! )
4R: value1, %var2%, value1, !var2!

C:\test>echo junk   | cmd /v:on /c echo 5R: %var1%, %var2%, !var1!, !var2!
5R: value1, %var2%, value1,

Delayed expansion is now OFF

C:\test>echo junk   | (cmd /v:on /c echo 6R: %var1%, %var2%, !var1!, !var2! )
6R: value1, %var2%, value1, !var2!

C:\test>echo junk   | cmd /v:on /c echo 7R: %var1%, %var2%, !var1!, !var2!
7R: value1, %var2%, value1, !var2!

我在管道的左侧和右侧都进行了测试,以证明处理在两侧是对称的。

测试1和2表明,在正常批处理情况下,括号对延迟扩展没有任何影响。

测试1L、1R:延迟扩展按预期工作。Var2未定义,因此%var2%和!var2!输出说明命令在命令行上下文中执行,而不是批处理上下文中执行。换句话说,使用命令行解析规则而不是批处理解析。(请参见Windows命令解释器(CMD.EXE)如何解析脚本?编辑-!VAR2!在父批处理上下文中扩展

测试2L、2R:括号禁用了延迟扩展!在我看来非常奇怪和意外。编辑-jeb认为这是微软的错误或设计缺陷。我同意,似乎没有任何理性的原因解释这种不一致的行为

测试3L、3R:setlocal EnableDelayedExpansion不起作用。但这是预期的,因为我们处于命令行上下文中。setlocal只在批处理上下文中起作用。

测试4L、4R:延迟扩展最初是启用的,但括号禁用了它。CMD /V:ON重新启用了延迟扩展,并且一切都按预期工作。我们仍然具有命令行上下文,输出与预期相同。

测试5L、5R:与4L、4R几乎相同,只是在执行CMD /V:on时已经启用了延迟扩展。 %var2%给出了预期的命令行上下文输出。但是,在批处理上下文中预期为空的!var2!输出。这是另一种非常奇怪和意外的行为。编辑-现在我知道!var2!在父批处理上下文中展开,所以实际上这很有意义

测试6L、6R、7L、7R:这些类似于测试4L/R、5L/R,只是这次延迟扩展起初被禁用了。这次所有4个场景都给出了预期的!var2!批处理上下文输出。

如果有人能为2L、2R和5L、5R的结果提供逻辑解释,那么我将选择它作为我的原始问题的答案。否则,我可能会接受此帖子作为答案(实际上更多是对发生情况的观察而不是答案)编辑-jab nailed it!


附录:作为对jeb评论的回应-这里有更多证据表明,批处理内的管道命令在命令行上下文中执行,而不是批处理上下文中执行。

这个批处理脚本:

@echo on
call echo batch context %%%%
call echo cmd line context %%%% | more

输出结果为:

C:\test>call echo batch context %%
batch context %

C:\test>call echo cmd line context %%   | more
cmd line context %%



最终补充说明

我添加了一些额外的测试和结果,证明了迄今为止所有的发现。我还演示了FOR变量扩展在管道处理之前发生的情况。最后,我展示了当多行块折叠成单行时,管道处理产生的一些有趣的副作用。

@echo off
cls
setlocal disableDelayedExpansion
set var1=value1
set "var2="
setlocal enableDelayedExpansion

echo on
@echo(
@echo Delayed expansion is ON
echo 1: %%, %%var1%%, %%var2%%, !var1!, ^^^!var1^^^!, !var2!, ^^^!var2^^^!, %%cmdcmdline%% | more
(echo 2: %%, %%var1%%, %%var2%%, !var1!, ^^^!var1^^^! !var2!, %%cmdcmdline%%) | more
for %%a in (Z) do (echo 3: %%a %%, %%var1%%, %%var2%%, !var1!, ^^^!var1^^^! !var2!, %%cmdcmdline%%) | more
(
  echo 4: part1
  set "var2=var2Value
  set var2
  echo "
  set var2
)
(
  echo 5: part1
  set "var2=var2Value
  set var2
  echo "
  set var2
  echo --- begin cmdcmdline ---
  echo %%cmdcmdline%%
  echo --- end cmdcmdline ---
) | more
(
  echo 6: part1
  rem Only this line remarked
  echo part2
)
(
  echo 7: part1
  rem This kills the entire block because the closing ) is remarked!
  echo part2
) | more

这是输出结果

Delayed expansion is ON

C:\test>echo 1: %, %var1%, %var2%, !var1!, ^!var1^!, !var2!, ^!var2^!, %cmdcmdline%   | more
1: %, value1, %var2%, value1, !var1!, , !var2!, C:\Windows\system32\cmd.exe  /S /D /c" echo 1: %, %var1%, %var2%, value1, !var1!, , !var2!, %cmdcmdline% "


C:\test>(echo 2: %, %var1%, %var2%, !var1!, ^!var1^! !var2!, %cmdcmdline% )  | more
2: %, value1, %var2%, !var1!, !var1! !var2!, C:\Windows\system32\cmd.exe  /S /D /c" ( echo 2: %, %var1%, %var2%, !var1!, ^!var1^! !var2!, %cmdcmdline% )"


C:\test>for %a in (Z) do (echo 3: %a %, %var1%, %var2%, !var1!, ^!var1^! !var2!, %cmdcmdline% )  | more

C:\test>(echo 3: Z %, %var1%, %var2%, !var1!, ^!var1^! !var2!, %cmdcmdline% )  | more
3: Z %, value1, %var2%, !var1!, !var1! !var2!, C:\Windows\system32\cmd.exe  /S /D /c" ( echo 3: Z %, %var1%, %var2%, !var1!, ^!var1^! !var2!, %cmdcmdline% )"

C:\test>(
echo 4: part1
 set "var2=var2Value
 set var2
 echo "
 set var2
)
4: part1
var2=var2Value
"
var2=var2Value

C:\test>(
echo 5: part1
 set "var2=var2Value
 set var2
 echo "
 set var2
 echo --- begin cmdcmdline ---
 echo %cmdcmdline%
 echo --- end cmdcmdline ---
)  | more
5: part1
var2=var2Value & set var2 & echo
--- begin cmdcmdline ---
C:\Windows\system32\cmd.exe  /S /D /c" ( echo 5: part1 & set "var2=var2Value
var2=var2Value & set var2 & echo
" & set var2 & echo --- begin cmdcmdline --- & echo %cmdcmdline% & echo --- end cmdcmdline --- )"
--- end cmdcmdline ---


C:\test>(
echo 6: part1
 rem Only this line remarked
 echo part2
)
6: part1
part2

C:\test>(echo %cmdcmdline%   & (
echo 7: part1
 rem This kills the entire block because the closing ) is remarked!
 echo part2
) )  | more

测试1和2总结了所有行为,%%cmdcmdline%%技巧真正有助于演示发生了什么。

测试3演示了FOR变量扩展仍然适用于带管道的块。

测试4/5和6/7展示了管道在多行块中工作的有趣副作用。小心!

我相信在复杂的管道场景中确定转义序列将是一场噩梦。


+1,我喜欢详尽的测试,但你的一些结论似乎是错误的。在我看来,你对1LR和5LR的解释是错误的。 - jeb
@jeb 你知道我的分析哪里出了问题吗?特别是在1LR方面,因为3LR的结果似乎支持我的结论。5LR对我来说仍然是个谜。 - dbenham
我编辑了我的回答,希望现在能够完整地解释清楚行为了 :-) - jeb
@jeb - 当然没问题!现在一切都有意义了。看看我在最后的补充中演示的一些副作用,它们涉及多行块如何被处理。这很棘手!但是一切都有意义。 - dbenham
我在我的答案中添加了一个关于使用REM或引号时&行为的解决方案。 - jeb

8
有趣的事情!我不知道答案,但我知道管道操作在Windows批处理中存在一致性故障,而在原始的MS-DOS批处理中不应该存在这种故障(如果旧的MS-DOS批处理可以执行此类功能),因此我怀疑是在开发新的Windows批处理功能时引入了错误。
以下是一些例子: echo Value to be assigned | set /p var= 上一行代码没有将值分配给变量,因此我们必须以以下方式进行修复: echo Value to be assigned > temp.txt & set /p var=< temp.txt 还有一个例子:
(
echo Value one
echo Value two
echo Value three
) | call :BatchSubroutine

无法正常工作。按以下方式修复:
(
echo Value one
echo Value two
echo Value three
) > temp.txt
call :BatchSubroutine < temp.txt

然而,在某些情况下,这种方法确实有效;例如,可以使用DEBUG.COM:
echo set tab=9> def_tab.bat
(
echo e108
echo 9
echo w
echo q
) | debug def_tab.bat
call def_tab
echo ONE%tab%TWO

之前的程序展示:

ONE     TWO

在哪些情况下起作用,哪些情况下不起作用?只有上帝(和微软)知道,但似乎与新的Windows批处理功能有关:SET /P命令、延迟扩展、圆括号中的代码块等。

编辑:异步批处理文件

注意:我修改了这一部分以纠正我的错误。请参见我对jeb的最后一条评论以获取详细信息。

正如jeb所说,管道的两端都会创建两个异步进程,这使得即使没有使用START命令也可以执行异步线程。

Mainfile.bat:

@echo off
echo Main start. Enter lines, type end to exit
First | Second
echo Main end

First.bat:

@echo off
echo First start

:loop
    set /P first=
    echo First read: %first%
if /I not "%first%" == "end" goto loop
echo EOF

echo First end

Second.bat:

@echo off
echo Second start

:loop
    set /P second=Enter line: 
    echo Second read: %second%
    echo/
if not "%second%" == "EOF" goto loop

echo Second end

我们可以利用这种能力开发一个程序,相当于Expect应用程序(以pexpect Python模块类似的方式工作),可以通过以下方式控制任何交互式程序:
Input | anyprogram | Output

Output.bat文件通过分析程序的输出来实现“Expect”部分,而Input.bat将通过提供输入来实现“Sendline”部分。从Output到Input模块的反向通信将通过一个带有所需信息的文件和一个简单的信号量系统来实现,该系统通过存在/缺失一个或两个标志文件来控制。


jeb的回答解释了为什么管道无法调用批处理函数:CALL :FUNCTION命令不在批处理上下文中执行,因此它不可能起作用。 - dbenham
你关于异步行为的编辑很好,但并不完全正确。即使管道在cmd上下文中首先启动,但如果你在那里启动一个批处理文件,你又回到了批处理上下文中。 - jeb
1
@jeb:你又说对了!在我开发示例时出现了一个奇怪的错误,让我感到困惑:( 我纠正了我的示例,删除了关于上下文的错误文本,并添加了一个可能的应用程序:类似Expect的程序。 - Aacini

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接