前几天在网上看到一个有意思的题,题目是设计一个代码行数统计工具。这类工具我经常会用到,但是具体是如何实现的呢?这个问题我还从未思考过,于是便试着做出这种工具。
题目描述是这样的:
题目要求:
请设计一个命令行程序:使用多线程,统计C\C++程序语言程序源代码行数;源代码是可以编译通过的合法的代码,统计其物理行数、其中的空行行数、其中含有的有效代码行数、其中含有的注释行数。
冲突处理:
在多行注释内的空行不算作注释行;一行中同时有代码和注释,需要同时算作有效代码行和注释行。
提示:
注意换行符和续行符、注意字符和字符串内部的处理和转义符、不要使用正则表达式、要求自己独立完成答题;如果代码框架能更容易的扩展到支持多种语言的源代码行数统计,将获得更高的评价。
提交要求:
命令行程序的输入:从命令行给程序输入一个文件夹的路径作为参数;程序统计其中(包含子文件夹)源代码文件的行数信息;(以参数方式传递路径,如启动命令counter.exe c:\src,不要在程序执行过程中手动输入任何内容,程序完成要自动退出)。
命令行程序的输出:输出结果到标准输出,每个代码文件输出一行结果,格式为 file:src.cpp total:123 empty:123 effective:123 comment:123 依次为文件名file(带工程目录内的相对路径)、物理行数total、空行行数empty、 有效代码行数effective、注释行数comment,每个数据以 key:value 的形式输出, key为以上使用的单词,请勿使用其他单词命名,数据间以空格分隔。
请附上最终汇总统计结果,并以以下格式展示:
----------------------------------------------------------
- Files Lines Code Comments Blanks
----------------------------------------------------------
-
10 100 100 39 40
----------------------------------------------------------
-
首先,这道题需要我们统计的是一个文件夹及其子文件夹中的所有源代码文件行数,所以第一步我们应该搜索符合条件的文件。
先以Windows环境下,以C/C++ 为例,我们需要搜索的文件扩展名为:.c、.h、.cpp、.hpp。利用递归实现对文件的搜索,代码如下:
def get_filelist(path, Filelist):
extendList = ['.c', '.h', '.cpp', '.hpp']
newPath = path
if os.path.isfile(path) and os.path.splitext(path)[1] in extendList:
#文件扩展名属于列表中其中一种时,文件路径添加到filelist中
Filelist.append(path)
elif os.path.isdir(path):
#路径为目录时,遍历目录下的所有文件和目录
for s in os.listdir(path):
newPath=os.path.join(path, s)
Counter.get_filelist(newPath, Filelist)
return Filelist
完成对文件的搜索后,下一步就是读取文件,然后逐行分析。
1. 首先是对物理行数total的统计,物理行数也就是文件所有内容的总行数,不受续行符的影响。统计方法是读取文件后,获取readlines()方法返回元素个数。
# 打开文件并获取所有行
fp = open(filename, encoding = 'gbk', errors = 'ignore')
lines = fp.readlines()
total = len(lines) # 总行数
需要注意的是,如果文件最后一行内容为空行,是不会存在列表中的。这是因为最后一行出现空行的原因是倒数第二行的末尾出现了换行符,而最后一行实际上什么都没有,所以不会存在列表中。这种情况需要另外进行判断。这一行空行是否要加入计数中也需要根据需求来,例如比较常用的代码行统计工具cloc工具是不计最后一行空行的,而vscode 用于统计代码行的扩展 VS Code Counter是计了最后一行空行的。如果要记录最后一行空行,则在所有行遍历结束后加上
if(lines[-1][-1] == '\n'):
total += 1 # 总行数+1
empty += 1 # 空白行+1
对物理行的计数记录下来之后,我们开始进行每行遍历,每一行在遍历开始前,需要去除行两边的空白符,还需要查看最后一个字符是否为续行符,若为续行符,则将本行内容拼接到下一行前面,然后跳过本行遍历进入下一行,再继续判断末尾是否有续行符。(这一步造成了空间在一定程度上的浪费,建议优化一下,笔者就懒得写了)
for line in lines:
line = temp + line
line = line.strip("\r\t ") # 去除两端空白
if line[-1] == "\\": # 检查末尾是否有续行符,若有续行符,则保存当前line值,准备与下一行进行拼接
temp += line[:-1]
continue
else:
temp = ""
2.对空白行blank的计数就比较简单了,遍历到的行如果只有一个字符’\n’则空白行数加一。
lineLen = len(line)
if lineLen == 1 and line == '\n':
#空行,空行数量+1
empty += 1
continue
3. 对有效代码行code行的计数,可以判断遇到的符号是不是注释符或当前状态是否在注释状态中,如果不是则为有效代码行,我们用"is_effective_code"来记录该行是否为有效代码行。
4. 对注释行comments的计数,需要特别注意的是题目中提到若同一行中有有效代码和注释,则此时代码行数和注释行数都要加一,这一点与cloc和vs code couter 都不同。我的思路是可以判断是否遇到了注释符,如果遇到的符号为’//‘则进入行注释状态,直到行字符遍历完毕,注释行加一。如果遇到了 ‘/*’ 则进入块注释状态,此时保留块注释状态去遍历接下来的每一行,每遍历完一行则注释行加一,直到遇到块注释结束符’*/'退出块注释模式。退出时,此行遍历不一定结束,程序仍需要进行后续字符的遍历,所以别忘了退出时要将退出行也加上。
对于注释行的判断,还有需要注意的地方,若注释符出现在字符串中的话,是不能够起到注释效果的,所以需要先考虑是否进入了字符串模式。(但是cloc和vs code couter都是将双引号中间的注释符当做是正常注释状态了,这部分有待讨论)
3和4 我们会遇到好几种状态,为保证代码的可读性,我们定义一组枚举变量表示目前处于哪种状态下。
# 将可能遇到的情况枚举
# Init:表示初始状态
# Common:表示普通状态
# CharString:表示字符串状态
# LineComment:表示行注释状态
# BlockComments:表示块注释状态
Status = Enum('Status','Init Common CharString LineComment BlockComments')
综上,3.&4.的实现代码如下
skipStep = 0 # 需要跳过的字符数,用于跳过一些符号,例如遇到//时进入行注释状态,跳过到//后面第一个
is_effective_code = False # 有效代码行标识
for i in range(lineLen):
if skipStep != 0:
skipStep -= 1
continue
if row_cur_status == Status.Common:
# 普通状态下
if line[i] == '"' or line[i] =="'":
row_cur_status = Status.CharString # 切换到字符串状态
CharStringStart = line[i] # 记录字符串开始时的标识符,用于判断后续退出位置
continue
# 检查是否进入行注释状态
if i + 1 < lineLen and line[i:i + 2] == '//':
row_cur_status = Status.LineComment # 切换到行注释状态
skipStep = 1
continue
# 检查是否进入块注释状态
if i + 1 < lineLen and line[i:i + 2] == '/*':
row_cur_status = Status.BlockComments # 切换到块注释状态
skipStep = 1
continue
if line[i] == '\n':
continue
if line[i] == ' ':
continue
else:
is_effective_code = True # 代码行有效
continue
elif row_cur_status == Status.CharString:
#字符串状态下
if line[i] == CharStringStart:
row_cur_status = Status.Common # 字符串结束,切换回普通状态
is_effective_code = True # 字符串也属于有效代码
continue
else:
continue
elif row_cur_status == Status.BlockComments:
# 块注释状态下
# 检查是否退出块注释状态
if i + 1 < lineLen and line[i:i + 2] == '*/':
# 退出块注释,注释行加上块注释的最后一行,切换回普通状态
comment_numbers += 1
row_cur_status = Status.Common
skipStep = 1
continue
else:
continue
# 单行遍历结束后,以当前状态记录行数
# 代码行有效,有效代码行数+1
if is_effective_code == True:
codes_numbers += 1
# 当前状态为块注释或行注释状态下,注释代码行数+1
if row_cur_status in (Status.BlockComments, Status.LineComment):
comment_numbers += 1
# 当前状态不为块注释时,进入下一行前,初始化当前状态
if row_cur_status != Status.BlockComments:
row_cur_status = Status.Common
最后我们加上一点点细节处理和输出,在main函数里加上一点点多线程,整合后的完整版如下:
from queue import Empty
import sys
import os
from enum import Enum
import time
from unittest.mock import patch
import threading
class Counter:
Line_numbers = 0
Code = 0
total_comment_numbers = 0
Blanks = 0
def get_filelist(path, Filelist):
extendList = ['.c', '.h', '.cpp', '.hpp']
newPath = path
if os.path.isfile(path) and os.path.splitext(path)[1] in extendList:
#文件扩展名属于列表中其中一种时,文件路径添加到filelist中
Filelist.append(path)
elif os.path.isdir(path):
#路径为目录时,遍历目录下的所有文件和目录
for s in os.listdir(path):
newPath=os.path.join(path, s)
Counter.get_filelist(newPath, Filelist)
return Filelist
def CodeCounter(filename, path):
codes_numbers = 0
empty = 0
comment_numbers = 0
# 打开文件并获取所有行
fp = open(filename, encoding = 'gbk', errors = 'ignore')
lines = fp.readlines()
row_cur_status = Status.Common # 设置初始状态为Common
temp = ""
for line in lines:
line = temp + line
line = line.strip("\r\t ") # 去除两端空白
if line[-1] == "\\": # 检查末尾是否有续行符,若有续行符,则保存当前line值,准备与下一行进行拼接
temp += line[:-1]
continue
else:
temp = ""
lineLen = len(line)
if lineLen == 1 and line == '\n':
#空行,空行数量+1
empty += 1
continue
skipStep = 0 # 需要跳过的字符数,用于跳过一些符号,例如遇到//时进入行注释状态,跳过到//后面第一个字符
is_effective_code = False # 有效代码行标识
for i in range(lineLen):
if skipStep != 0:
skipStep -= 1
continue
if row_cur_status == Status.Common:
# 普通状态下
if line[i] == '"' or line[i] =="'":
row_cur_status = Status.CharString # 切换到字符串状态
CharStringStart = line[i] # 记录字符串开始时的标识符,用于判断后续退出位置
continue
# 检查是否进入行注释状态
if i + 1 < lineLen and line[i:i + 2] == '//':
row_cur_status = Status.LineComment # 切换到行注释状态
skipStep = 1
continue
# 检查是否进入块注释状态
if i + 1 < lineLen and line[i:i + 2] == '/*':
row_cur_status = Status.BlockComments # 切换到块注释状态
skipStep = 1
continue
if line[i] == '\n':
continue
if line[i] == ' ':
continue
else:
is_effective_code = True # 代码行有效
continue
elif row_cur_status == Status.CharString:
#字符串状态下
if line[i] == CharStringStart:
row_cur_status = Status.Common # 字符串结束,切换回普通状态
is_effective_code = True # 字符串也属于有效代码
continue
else:
continue
elif row_cur_status == Status.BlockComments:
# 块注释状态下
# 检查是否退出块注释状态
if i + 1 < lineLen and line[i:i + 2] == '*/':
# 退出块注释,注释行加上块注释的最后一行,切换回普通状态
comment_numbers += 1
row_cur_status = Status.Common
skipStep = 1
continue
else:
continue
# 单行遍历结束后,以当前状态记录行数
# 代码行有效,有效代码行数+1
if is_effective_code == True:
codes_numbers += 1
# 当前状态为块注释或行注释状态下,注释代码行数+1
if row_cur_status in (Status.BlockComments, Status.LineComment):
comment_numbers += 1
# 当前状态不为块注释时,进入下一行前,初始化当前状态
if row_cur_status != Status.BlockComments:
row_cur_status = Status.Common
total = len(lines)
if(lines[-1][-1] == '\n'):
total += 1
empty += 1
fp.close()
print("file:{0} total:{1} empty:{2} effective:{3} comment:{4}".format(filename.replace(path + "\\", ""), total, empty, codes_numbers, comment_numbers))
Counter.Line_numbers += total
Counter.Blanks += empty
Counter.Code += codes_numbers
Counter.total_comment_numbers += comment_numbers
if __name__ == "__main__":
path = os.path.abspath(sys.argv[1]) #获取命令行输入的文件夹绝对路径
# path = r"C:\Users\Undefined\Desktop\test\Osiris"
list = Counter.get_filelist(path, [])
threads = []
# 将可能遇到的情况枚举
# Common:表示普通状态
# CharString:表示字符串状态
# LineComment:表示行注释状态
# BlockComments:表示块注释状态
Status = Enum('Status','Init Common CharString LineComment BlockComments')
for file in list:
t = threading.Thread(target=Counter.CodeCounter,args=(file, path))
threads.append(t)
for thr in threads:
thr.start()
for the in threads:
thr.join()
time.sleep(0.1)
print("-"*56)
print("- {0:<10} {1:<10} {2:<10} {3:<10} {4:<10}".format("Files", "Lines", "Code", "Comments", "Blanks"))
print("-"*56)
print(" {0:<10} {1:<10} {2:<10} {3:<10} {4:<10}".format(len(list), Counter.Line_numbers, Counter.Code, Counter.total_comment_numbers, Counter.Blanks))
print("-"*56)
最后奉上部分结果截图
那么问题来了,题目还有一个要求是尽可能容易扩展到其他语言的代码行,这时候我们可以把不同语言的续行符、行注释符、块注释符等符号放入列表或者字典中,每次判断该语言有哪些相对应的符号即可。我按照自己的想法做了一个,代码太多我怕太乱了,放到了另一篇文章中,详细代码见我的另一篇文章。
Python实现一个代码行数统计工具(易拓展到其他语言版)
第一次写比较长的文章,表达的可能不是很清晰,文章若有任何不明白的地方可以私聊我🙆♂️
若文中有错误,还请大佬在评论区指正,我会好好学习和改进,谢谢大佬们🙇