如何统计特定扩展名的文件以及它们所在的目录?

我想知道在一个复杂的目录结构中有多少个扩展名为.c的普通文件,以及这些文件分布在多少个目录中。我只需要输出这两个数字。
关于如何获取文件数量,我看到了这个问题,但我还需要知道文件所在的目录数量。
  • 我的文件名(包括目录)可能包含任何字符;它们可能以.-开头,并且包含空格或换行符。
  • 我可能有一些以.c结尾的符号链接,以及指向目录的符号链接。我不希望跟随或计算符号链接,或者至少想知道它们是否被计算。
  • 目录结构有很多层次,顶层目录(工作目录)中至少有一个.c文件。
我匆忙地在(Bash)shell中编写了一些命令来自己计数,但我不认为结果准确...
shopt -s dotglob
shopt -s globstar
mkdir out
for d in **/; do
     find "$d" -maxdepth 1 -type f -name "*.c" >> out/$(basename "$d")
done
ls -1Aq out | wc -l
cat out/* | wc -l

这会输出关于模糊重定向的投诉,错过了当前目录中的文件,并且在特殊字符上出现问题(例如,重定向的find输出在文件名中打印换行符),并写入了一堆空文件(糟糕)。
我如何可靠地枚举我的.c文件及其所在的目录?
如果有帮助的话,这里是一些命令来创建一个包含糟糕命名和符号链接的测试结构:
mkdir -p cfiles/{1..3}/{a..b} && cd cfiles
mkdir space\ d
touch -- i.c -.c bad\ .c 'terrible
.c' not-c .hidden.c
for d in space\ d 1 2 2/{a..b} 3/b; do cp -t "$d" -- *.c; done
ln -s 2 dirlink
ln -s 3/b/i.c filelink.c

在生成的结构中,有7个目录包含.c文件,并且有29个常规文件以.c结尾(如果在运行命令时关闭了dotglob选项)(如果我数错了,请告诉我)。这些是我想要的数字。
请随意使用这个特定的测试。
注意:对于任何shell或其他语言的答案,我将进行测试并感激。如果我需要安装新的软件包,没问题。如果您知道GUI解决方案,欢迎分享(但我可能不会安装整个桌面环境来测试):)我使用Ubuntu MATE 17.10。

编写一个处理不良编程习惯的程序结果证明相当具有挑战性 ;) - WinEunuuchs2Unix
8个回答

我还没有检查符号链接的输出,但是:
find . -type f -iname '*.c' -printf '%h\0' |
  sort -z |
  uniq -zc |
  sed -zr 's/([0-9]) .*/\1 1/' |
  tr '\0' '\n' |
  awk '{f += $1; d += $2} END {print f, d}'
  • find命令会打印出每个找到的.c文件所在的目录名。
  • sort | uniq -c将告诉我们每个目录中有多少个文件(这里可能不需要sort,不太确定)。
  • 使用sed,我将目录名替换为1,从而消除所有可能的奇怪字符,只保留计数和1
  • 通过启用tr,我可以将输出转换为以换行符分隔的格式。
  • 然后我使用awk来汇总数据,以获取文件的总数以及包含这些文件的目录数。请注意,这里的d实际上与NR是相同的。我本可以省略在sed命令中插入1,并在这里直接打印NR,但我认为这样稍微更清晰一些。

tr之前,数据是以NUL分隔的,对所有有效的文件名都是安全的。


使用zsh和bash,您可以使用printf %q来获取带引号的字符串,其中不会包含换行符。因此,您可以尝试做类似以下的操作:
shopt -s globstar dotglob nocaseglob
printf "%q\n" **/*.c | awk -F/ '{NF--; f++} !c[$0]++{d++} END {print f, d}'

然而,尽管**不应该扩展到目录的符号链接, 但我在bash 4.4.18(1) (Ubuntu 16.04)上无法获得期望的输出。
$ shopt -s globstar dotglob nocaseglob
$ printf "%q\n" ./**/*.c | awk -F/ '{NF--; f++} !c[$0]++{d++} END {print f, d}'
34 15
$ echo $BASH_VERSION
4.4.18(1)-release

但是zsh运行得很好,而且命令可以简化为:
$ printf "%q\n" ./**/*.c(D.:h) | awk '!c[$0]++ {d++} END {print NR, d}'
29 7

D使得这个glob可以选择点文件,.选择普通文件(所以不包括符号链接),:h只打印目录路径而不是文件名(类似于find%h)(参见文件名生成修饰符部分)。因此,使用awk命令我们只需要计算出现的唯一目录数量,而行数就是文件数量。


太棒了!只使用了必要的东西,没有多余的。谢谢你的教导 :) - Zanna
@Zanna 如果你发布一些用于重新创建带有符号链接的目录结构的命令,并附上预期的带有符号链接的输出,我可能能够相应地修复这个问题。 - muru
我已经添加了一些命令,以创建一个(通常情况下过于复杂的)带有符号链接的测试结构。 - Zanna
@Zanna 我认为这个命令不需要任何调整就可以得到 29 7。如果我在 find 后面加上 -L,输出会变成 41 10。你需要哪个输出? - muru
“29 7” 是我想要的,我会进行编辑以使其更清楚。不需要做任何事情。我只是提到符号链接(就像Eliah Kagan 在聊天中建议的)以防回答者想要讨论他们的方法如何处理符号链接,这样有类似任务的人就不会疑惑它们是否被计算在内了。 - Zanna
1增加了一个zsh + awk的方法。可能有一些方法可以让zsh自己为我打印计数,但是不知道怎么做。 - muru

Python有os.walk,它使得像这样的任务变得容易、直观,并且在面对包含换行符等奇怪文件名时自动强大。这个Python 3脚本最初是我在聊天中发布的链接,意图在当前目录下运行(但不必位于当前目录中,而且您可以更改它传递给os.walk的路径):
#!/usr/bin/env python3

import os

dc = fc = 0
for _, _, fs in os.walk('.'):
    c = sum(f.endswith('.c') for f in fs)
    if c:
        dc += 1
        fc += c
print(dc, fc)

这将打印直接包含至少一个以.c结尾的文件的目录数量,后跟一个空格,再跟上以.c结尾的文件数量。"隐藏"文件--即以.开头的文件--也会被计算在内,并且隐藏目录同样会被遍历。

os.walk递归遍历目录层次结构。它枚举了从给定起始点递归访问的所有目录,并以三个值的元组形式提供有关每个目录的信息:root, dirs, files。对于它遍历到的每个目录(包括您给出的第一个目录名):

  • root保存了该目录的路径名。请注意,这与系统的“根目录”/(以及/root)完全无关,尽管如果您从那里开始,它也会进入那些目录。在这种情况下,root从路径.开始——即当前目录——并遍历其下的所有目录。
  • dirs保存了当前保存在root中的目录的所有子目录的路径名列表。
  • files保存了当前保存在root中的目录中所有不是目录的文件的路径名列表。请注意,这包括其他类型的文件,包括符号链接,但听起来您不希望看到任何以.c结尾的条目,并且有兴趣查看任何这样的条目。
在这种情况下,我只需要检查元组的第三个元素files(在脚本中称为fs)。与find命令类似,Python的os.walk会自动遍历子目录;我唯一需要自己检查的是每个子目录中文件的名称。不过,与find命令不同的是,os.walk会自动提供一个包含这些文件名的列表。 该脚本不会跟随符号链接。对于这样的操作,您很可能不希望跟随符号链接,因为它们可能形成循环,并且即使没有循环,如果通过不同的符号链接可以访问相同的文件和目录,则可能多次遍历和计数它们。
如果你想要os.walk跟随符号链接(通常是不需要的),那么你可以将followlinks=true作为参数传递给它。换句话说,你可以使用os.walk('.', followlinks=true)来代替os.walk('.')。我再次强调,你很少会需要这样做,尤其在像这样递归枚举整个目录结构并计算满足某些条件的文件数量的任务中。

找到 + Perl:
$ find . -type f -iname '*.c' -printf '%h\0' | 
    perl -0 -ne '$k{$_}++; }{ print scalar keys %k, " $.\n" '
7 29

解释

find 命令将查找所有普通文件(不包括符号链接或目录),然后打印它们所在的目录名(%h),后跟 \0

  • perl -0 -ne:逐行读取输入(-n),并对每一行应用由-e给出的脚本。 -0将输入行分隔符设置为\0,以便我们可以读取以空字符分隔的输入。
  • $k{$_}++$_是一个特殊变量,它取当前行的值。这被用作哈希%k的键,其值是每个输入行(目录名)出现的次数。
  • }{:这是写END{}的简写方式。在}{之后的任何命令都将在所有输入被处理后执行一次。
  • print scalar keys %k, " $.\n"keys %k返回哈希%k中键的数组。 scalar keys %k给出该数组中元素的数量,即目录的数量。这与$.的当前值一起打印,$.是一个特殊变量,保存当前输入行号。由于此操作在最后运行,当前输入行号将是最后一行的行号,因此是迄今为止已经看到的行数。
你可以将perl命令扩展为以下形式,以增加可读性:
find  . -type f -iname '*.c' -printf '%h\0' | 
    perl -0 -e 'while($line = <STDIN>){
                    $dirs{$line}++; 
                    $tot++;
                } 
                $count = scalar keys %dirs; 
                print "$count $tot\n" '

这是我的建议:
#!/bin/bash
tempfile=$(mktemp)
find -type f -name "*.c" -prune >$tempfile
grep -c / $tempfile
sed 's_[^/]*$__' $tempfile | sort -u | grep -c /

这个简短的脚本会创建一个临时文件,在当前目录及其子目录中找到所有以.c结尾的文件,并将列表写入临时文件中。然后使用grep命令两次进行文件计数(参考如何使用命令行获取目录中的文件计数?):第二次使用sort -u移除重复列出的目录,之前使用sed从每行中删除文件名。

对于包含换行符的文件名,该脚本也能正常工作:grep -c /只计算包含斜杠的行,因此仅考虑列表中多行文件名的第一行。

输出

$ tree
.
├── 1
│   ├── 1
│   │   ├── test2.c
│   │   └── test.c
│   └── 2
│       └── test.c
└── 2
    ├── 1
    │   └── test.c
    └── 2

$ tempfile=$(mktemp);find -type f -name "*.c" -prune >$tempfile;grep -c / $tempfile;sed 's_[^/]*$__' $tempfile | sort -u | grep -c /
4
3

小型Shell脚本

我建议使用一个小的bash shell脚本,其中包含两个主要命令行(以及一个变量filetype,以便轻松切换以查找其他文件类型)。

它不会查找或在符号链接中查找,只查找常规文件。

#!/bin/bash

filetype=c
#filetype=pdf

# count the 'filetype' files

find -type f -name "*.$filetype" -ls|sed 's#.* \./##'|wc -l | tr '\n' ' '

# count directories containing 'filetype' files

find -type d -exec bash -c "ls -AF '{}'|grep -e '\.'${filetype}$ -e '\.'${filetype}'\*'$ > /dev/null && echo '{} contains file(s)'" \;|grep 'contains file(s)$'|wc -l

冗长的shell脚本

这是一个更冗长的版本,也考虑了符号链接。

#!/bin/bash

filetype=c
#filetype=pdf

# counting the 'filetype' files

echo -n "number of $filetype files in the current directory tree: "
find -type f -name "*.$filetype" -ls|sed 's#.* \./##'|wc -l

echo -n "number of $filetype symbolic links in the current directory tree: "
find -type l -name "*.$filetype" -ls|sed 's#.* \./##'|wc -l
echo -n "number of $filetype normal files in the current directory tree: "
find -type f -name "*.$filetype" -ls|sed 's#.* \./##'|wc -l
echo -n "number of $filetype symbolic links in the current directory tree including linked directories: "
find -L -type f -name "*.$filetype" -ls 2> /tmp/c-counter |sed 's#.* \./##' | wc -l; cat /tmp/c-counter; rm /tmp/c-counter

# list directories with and without 'filetype' files (good for manual checking; comment away after test)
echo '---------- list directories:'
 find    -type d -exec bash -c "ls -AF '{}'|grep -e '\.'${filetype}$ -e '\.'${filetype}'\*'$ > /dev/null && echo '{} contains file(s)' || echo '{} empty'" \;
echo ''
#find -L -type d -exec bash -c "ls -AF '{}'|grep -e '\.'${filetype}$ -e '\.'${filetype}'\*'$ > /dev/null && echo '{} contains file(s)' || echo '{} empty'" \;

# count directories containing 'filetype' files

echo -n "number of directories with $filetype files: "
find -type d -exec bash -c "ls -AF '{}'|grep -e '\.'${filetype}$ -e '\.'${filetype}'\*'$ > /dev/null && echo '{} contains file(s)'" \;|grep 'contains file(s)$'|wc -l

# list and count directories including symbolic links, containing 'filetype' files
echo '---------- list all directories including symbolic links:'
find -L -type d -exec bash -c "ls -AF '{}' |grep -e '\.'${filetype}$ -e '\.'${filetype}'\*'$ > /dev/null && echo '{} contains file(s)' || echo '{} empty'" \;
echo ''
echo -n "number of directories (including symbolic links) with $filetype files: "
find -L -type d -exec bash -c "ls -AF '{}'|grep -e '\.'${filetype}$ -e '\.'${filetype}'\*'$ > /dev/null && echo '{} contains file(s)'" \; 2>/dev/null |grep 'contains file(s)$'|wc -l

# count directories without 'filetype' files (good for checking; comment away after test)

echo -n "number of directories without $filetype files: "
find -type d -exec bash -c "ls -AF '{}'|grep -e '\.'${filetype}$ -e '\.'${filetype}'\*'$ > /dev/null || echo '{} empty'" \;|grep 'empty$'|wc -l

测试输出

从短的shell脚本:

$ ./ccntr 
29 7

从冗长的shell脚本中:
$ LANG=C ./c-counter
number of c files in the current directory tree: 29
number of c symbolic links in the current directory tree: 1
number of c normal files in the current directory tree: 29
number of c symbolic links in the current directory tree including linked directories: 42
find: './cfiles/2/2': Too many levels of symbolic links
find: './cfiles/dirlink/2': Too many levels of symbolic links
---------- list directories:
. empty
./cfiles contains file(s)
./cfiles/2 contains file(s)
./cfiles/2/b contains file(s)
./cfiles/2/a contains file(s)
./cfiles/3 empty
./cfiles/3/b contains file(s)
./cfiles/3/a empty
./cfiles/1 contains file(s)
./cfiles/1/b empty
./cfiles/1/a empty
./cfiles/space d contains file(s)

number of directories with c files: 7
---------- list all directories including symbolic links:
. empty
./cfiles contains file(s)
./cfiles/2 contains file(s)
find: './cfiles/2/2': Too many levels of symbolic links
./cfiles/2/b contains file(s)
./cfiles/2/a contains file(s)
./cfiles/3 empty
./cfiles/3/b contains file(s)
./cfiles/3/a empty
./cfiles/dirlink empty
find: './cfiles/dirlink/2': Too many levels of symbolic links
./cfiles/dirlink/b contains file(s)
./cfiles/dirlink/a contains file(s)
./cfiles/1 contains file(s)
./cfiles/1/b empty
./cfiles/1/a empty
./cfiles/space d contains file(s)

number of directories (including symbolic links) with c files: 9
number of directories without c files: 5
$ 

简单的Perl一行代码:
perl -MFile::Find=find -le'find(sub{/\.c\z/ and -f and $c{$File::Find::dir}=++$c}, @ARGV); print 0 + keys %c, " $c"' dir1 dir2

或者更简单的是使用find命令:
find dir1 dir2 -type f -name '*.c' -printf '%h\0' | perl -l -0ne'$c{$_}=1}{print 0 + keys %c, " $."'

如果你喜欢打高尔夫,并且拥有最近(不超过十年)的Perl编程经验:
perl -MFile::Find=find -E'find(sub{/\.c$/&&-f&&($c{$File::Find::dir}=++$c)},".");say 0+keys%c," $c"'

find -type f -name '*.c' -printf '%h\0'|perl -0nE'$c{$_}=1}{say 0+keys%c," $."'

考虑使用比find命令更快的locate命令。

在测试数据上运行

$ sudo updatedb # necessary if files in focus were added `cron` daily.
$ printf "Number Files: " && locate -0r "$PWD.*\.c$" | xargs -0 -I{} sh -c 'test ! -L "$1" && echo "regular file"' _  {} | wc -l &&  printf "Number Dirs.: " && locate -r "$PWD.*\.c$" | sed 's%/[^/]*$%/%' | uniq -cu | wc -l
Number Files: 29
Number Dirs.: 7

感谢Muru在Unix & Linux answer中的回答,帮助我解决了文件计数中的符号链接问题。
感谢Terdon在Unix & Linux answer中提供的$PWD的答案(虽然不是针对我)。

原始答案如下,参考评论

简略版:

$ cd /
$ sudo updatedb
$ printf "Number Files: " && locate -cr "$PWD.*\.c$"
Number Files: 3523
$ printf "Number Dirs.: " && locate -r "$PWD.*\.c$" | sed 's%/[^/]*$%/%' | uniq -c | wc -l 
Number Dirs.: 648
  • sudo updatedb 更新由locate命令使用的数据库,如果今天创建了.c文件或者今天删除了.c文件。
  • locate -cr "$PWD.*\.c$" 在当前目录及其子目录($PWD)中定位所有.c文件。使用-c参数来打印计数而不是文件名。使用r指定正则表达式,而不是默认的*pattern*匹配,以避免产生过多结果。
  • locate -r "$PWD.*\.c$" | sed 's%/[^/]*$%/%' | uniq -c | wc -l 在当前目录及其子目录中定位所有*.c文件。使用sed删除文件名,只保留目录名。使用uniq -c计算每个目录中的文件数量。使用wc -l计算目录数量。

从当前目录开始使用一行命令

$ cd /usr/src
$ printf "Number Files: " && locate -cr "$PWD.*\.c$" &&  printf "Number Dirs.: " && locate -r "$PWD.*\.c$" | sed 's%/[^/]*$%/%' | uniq -c | wc -l
Number Files: 3430
Number Dirs.: 624

请注意文件计数和目录计数的变化。我相信所有用户都有"/usr/src"目录,并且可以根据已安装内核的数量运行上述命令,计数会有所不同。
长格式如下:
长格式包括时间,这样您就可以看到"locate"比"find"快多少。即使您必须运行"sudo updatedb",它也比单个"find /"快很多倍。
───────────────────────────────────────────────────────────────────────────────────────────
rick@alien:~/Downloads$ sudo time updatedb
0.58user 1.32system 0:03.94elapsed 48%CPU (0avgtext+0avgdata 7568maxresident)k
48inputs+131920outputs (1major+3562minor)pagefaults 0swaps
───────────────────────────────────────────────────────────────────────────────────────────
rick@alien:~/Downloads$ time (printf "Number Files: " && locate -cr $PWD".*\.c$")
Number Files: 3523

real    0m0.775s
user    0m0.766s
sys     0m0.012s
───────────────────────────────────────────────────────────────────────────────────────────
rick@alien:~/Downloads$ time (printf "Number Dirs.: " && locate -r $PWD".*\.c$" | sed 's%/[^/]*$%/%' | uniq -c | wc -l) 
Number Dirs.: 648

real    0m0.778s
user    0m0.788s
sys     0m0.027s
───────────────────────────────────────────────────────────────────────────────────────────

注意:这是所有驱动器和分区上的所有文件。也就是说,我们可以搜索Windows命令。
$ time (printf "Number Files: " && locate *.exe -c)
Number Files: 6541

real    0m0.946s
user    0m0.761s
sys     0m0.060s
───────────────────────────────────────────────────────────────────────────────────────────
rick@alien:~/Downloads$ time (printf "Number Dirs.: " && locate *.exe | sed 's%/[^/]*$%/%' | uniq -c | wc -l) 
Number Dirs.: 3394

real    0m0.942s
user    0m0.803s
sys     0m0.092s

我有三个Windows 10的NTFS分区自动挂载在/etc/fstab中。注意,locate知道一切! 有趣的计数:
$ time (printf "Number Files: " && locate / -c &&  printf "Number Dirs.: " && locate / | sed 's%/[^/]*$%/%' | uniq -c | wc -l)
Number Files: 1637135
Number Dirs.: 286705

real    0m15.460s
user    0m13.471s
sys     0m2.786s

在286,705个目录中计算1,637,135个文件需要15秒。结果可能因人而异。

关于locate命令的正则表达式处理的详细说明(在这个问答中似乎不需要,但以防万一)请阅读此文档:在特定目录下使用"locate"命令?

最近文章的额外阅读:


1这不包括特定目录中的文件。正如你所指出的,它计算所有匹配.c(请注意,如果当前目录中有名为-.c的文件,则会中断因为您没有引用*.c)的所有文件(或目录或任何其他类型的文件),然后将打印系统中的所有目录,无论它们是否包含.c文件。 - terdon
@terdon 你可以传递一个目录 ~/my_c_progs/*.c。它正在计算具有 .c 程序的 638 个目录,总目录稍后显示为 286,705。我会修改答案以加上双引号 "*.c"。谢谢你的建议。 - WinEunuuchs2Unix
3是的,你可以使用类似于locate -r "/path/to/dir/.*\.c$"这样的命令,但是在你的回答中没有提到这一点。你只给出了另一个回答的链接,该回答提到了这一点,但没有解释如何将其调整为回答这里提出的问题。你整个回答都集中在如何计算系统上文件和目录的总数,这与所问的问题无关,问题是“如何计算特定目录中的.c文件数量以及包含.c文件的目录数量”。此外,你给出的数字是错误的,在原帖的示例中试一下就知道了。 - terdon
@terdon 感谢你的建议。我已经根据你的建议和你在其他SE网站上发布的关于$PWD变量的答案进行了改进:https://unix.stackexchange.com/a/188191/200094 - WinEunuuchs2Unix
1现在你必须确保$PWD中不包含可能在正则表达式中具有特殊意义的字符。 - muru
@muru 我开始讨厌正则表达式了。在我的测试中,无论是否使用它,即同样的 .c 文件计数和目录计数没有变化。请注意,我文件名中没有特殊字符,比如 Zanna。我目录名中有 -,例如 linux-headers-4.4.0-98,它能正常工作。你是在暗示可能存在 NL 换行字符或其他什么吗?尝试察觉问题比解决已知问题要困难得多。 - WinEunuuchs2Unix
当然,你必须确保相关文件夹被locate索引。 - muru
@muru 在我的系统上,locate 命令会索引 /etc/fstab 中的所有内容,其中包括两个硬盘和五个分区。但它会排除手机和 USB 设备。 - WinEunuuchs2Unix
谁知道你对系统做了什么?所以你不会修复答案来考虑PWD包含特殊字符或者它没有被locate索引的情况吗? - muru
@muru 我之前读到将$PWD放在"*.c"之外可以避免正则表达式特殊字符处理。我还创建了一个带有空格的目录并将一个C程序复制到其中,它能够正确运行。这与以下链接中的内容相符:https://apple.stackexchange.com/questions/52459/is-it-possible-to-have-bash-escape-spaces-in-pwd?utm_medium=organic&utm_source=google_rich_qa&utm_campaign=google_rich_qa 此外,我还读到sudo updatedb可以正确处理索引。也许Zanna可以比较我们两个答案的数据,并告诉我们计数是否不同。 - WinEunuuchs2Unix
空格不是正则表达式的特殊字符。引号被你的shell移除,locate命令从未看到过它们。 - muru
@muru 看起来现在工作正常,只是我得到了30个文件,而其他人只得到了29个文件。 - WinEunuuchs2Unix
当我们只计算常规文件时,你要计算cfiles/filelink.c这个符号链接,但它不会被计算在内。 - sudodus
@sudodus 谢谢你注意到这个问题。我下班后会进行微调。 - WinEunuuchs2Unix
...或者提供一个仅适用于普通文件的方法,以及一个包括符号链接的方法。 - sudodus
在上班前,我正在尝试使用test ! -h,这样第二种情况下就可以省略!(非操作符),如果test命令按预期工作的话。 - WinEunuuchs2Unix

我看到有人使用os.walk发布了一个解决方案,但它有一些限制。
它在处理大型目录时效果不佳(bug-report),而且很难内联使用。
在这里,glob可能更适合。
这个内联命令用于计算/tmp/目录中txt文件的数量。 python -c "import glob; print(len(glob.glob('/tmp/*.txt')))"