[关闭]
@cloverwang 2016-07-04T02:04:04.000000Z 字数 21669 阅读 3181

Python实现代码统计工具——终极加速篇

Python CPython+ctypes Pypy+cffi

声明

本文对于先前系列文章中实现的C/Python代码统计工具(CPLineCounter),通过C扩展接口重写核心算法加以优化,并与网上常见的统计工具做对比。实测表明,CPLineCounter在统计精度和性能方面均优于其他同类统计工具。以千万行代码为例评测性能,CPLineCounter在Cpython和Pypy环境下运行时,比国外统计工具cloc1.64分别快14.5倍和29倍,比国内SourceCounter3.4分别快1.8倍和3.6倍。

运行测试环境

本文基于Windows系统平台,运行和测试所涉及的代码实例。平台信息如下:

  1. >>> import sys, platform
  2. >>> print '%s %s, Python %s' %(platform.system(), platform.release(), platform.python_version())
  3. Windows XP, Python 2.7.11
  4. >>> sys.version
  5. '2.7.11 (v2.7.11:6d1b6a68f775, Dec 5 2015, 20:32:19) [MSC v.1500 32 bit (Intel)]'

注意,Python不同版本间语法存在差异,故文中某些代码实例需要稍作修改,以便在低版本Python环境中运行。

一. 代码实现与优化

为避免碎片化,本节将给出完整的实现代码。注意,本节某些变量或函数定义与先前系列文章中的实现存在细微差异,请注意甄别。

1.1 代码实现

首先,定义两个存储统计结果的列表:

  1. import os, sys
  2. rawCountInfo = [0, 0, 0, 0, 0]
  3. detailCountInfo = []

其中,rawCountInfo存储粗略的文件总行数信息,列表元素依次为文件行、代码行、注释行和空白行的总数,以及文件数目。detailCountInfo存储详细的统计信息,包括单个文件的行数信息和文件名,以及所有文件的行数总和。

以下将给出具体的实现代码。为避免大段粘贴代码,以函数为片段简要描述。

  1. def CalcLinesCh(line, isBlockComment):
  2. lineType, lineLen = 0, len(line)
  3. if not lineLen:
  4. return lineType
  5. line = line + '\n' #添加一个字符防止iChar+1时越界
  6. iChar, isLineComment = 0, False
  7. while iChar < lineLen:
  8. if line[iChar] == ' ' or line[iChar] == '\t': #空白字符
  9. iChar += 1; continue
  10. elif line[iChar] == '/' and line[iChar+1] == '/': #行注释
  11. isLineComment = True
  12. lineType |= 2; iChar += 1 #跳过'/'
  13. elif line[iChar] == '/' and line[iChar+1] == '*': #块注释开始符
  14. isBlockComment[0] = True
  15. lineType |= 2; iChar += 1
  16. elif line[iChar] == '*' and line[iChar+1] == '/': #块注释结束符
  17. isBlockComment[0] = False
  18. lineType |= 2; iChar += 1
  19. else:
  20. if isLineComment or isBlockComment[0]:
  21. lineType |= 2
  22. else:
  23. lineType |= 1
  24. iChar += 1
  25. return lineType #Bitmap:0空行,1代码,2注释,3代码和注释
  26. def CalcLinesPy(line, isBlockComment):
  27. #isBlockComment[single quotes, double quotes]
  28. lineType, lineLen = 0, len(line)
  29. if not lineLen:
  30. return lineType
  31. line = line + '\n\n' #添加两个字符防止iChar+2时越界
  32. iChar, isLineComment = 0, False
  33. while iChar < lineLen:
  34. if line[iChar] == ' ' or line[iChar] == '\t': #空白字符
  35. iChar += 1; continue
  36. elif line[iChar] == '#': #行注释
  37. isLineComment = True
  38. lineType |= 2
  39. elif line[iChar:iChar+3] == "'''": #单引号块注释
  40. if isBlockComment[0] or isBlockComment[1]:
  41. isBlockComment[0] = False
  42. else:
  43. isBlockComment[0] = True
  44. lineType |= 2; iChar += 2
  45. elif line[iChar:iChar+3] == '"""': #双引号块注释
  46. if isBlockComment[0] or isBlockComment[1]:
  47. isBlockComment[1] = False
  48. else:
  49. isBlockComment[1] = True
  50. lineType |= 2; iChar += 2
  51. else:
  52. if isLineComment or isBlockComment[0] or isBlockComment[1]:
  53. lineType |= 2
  54. else:
  55. lineType |= 1
  56. iChar += 1
  57. return lineType #Bitmap:0空行,1代码,2注释,3代码和注释

CalcLinesCh()和CalcLinesPy()函数分别基于C和Python语法判断文件行属性,按代码、注释或空行分别统计。

  1. from ctypes import c_uint, c_ubyte, CDLL
  2. CFuncObj = None
  3. def LoadCExtLib():
  4. try:
  5. global CFuncObj
  6. CFuncObj = CDLL('CalcLines.dll')
  7. except Exception: #不捕获系统退出(SystemExit)和键盘中断(KeyboardInterrupt)异常
  8. pass
  9. def CalcLines(fileType, line, isBlockComment):
  10. try:
  11. #不可将CDLL('CalcLines.dll')放于本函数内,否则可能严重拖慢执行速度
  12. bCmmtArr = (c_ubyte * len(isBlockComment))(*isBlockComment)
  13. CFuncObj.CalcLinesCh.restype = c_uint
  14. if fileType is 'ch': #is(同一性运算符)判断对象标识(id)是否相同,较==更快
  15. lineType = CFuncObj.CalcLinesCh(line, bCmmtArr)
  16. else:
  17. lineType = CFuncObj.CalcLinesPy(line, bCmmtArr)
  18. isBlockComment[0] = True if bCmmtArr[0] else False
  19. isBlockComment[1] = True if bCmmtArr[1] else False
  20. #不能采用以下写法,否则本函数返回后isBlockComment列表内容仍为原值
  21. #isBlockComment = [True if i else False for i in bCmmtArr]
  22. except Exception, e:
  23. #print e
  24. if fileType is 'ch':
  25. lineType = CalcLinesCh(line, isBlockComment)
  26. else:
  27. lineType = CalcLinesPy(line, isBlockComment)
  28. return lineType

为提升运行速度,作者将CalcLinesCh()和CalcLinesPy()函数用C语言重写,并编译生成动态链接库。这两个函数的C语言版本实现和使用详见1.2小节。LoadCExtLib()和CalcLines()函数旨在加载该动态链接库并执行相应的C版本统计函数,若加载失败则执行较慢的Python版本统计函数。

上述代码运行于CPython环境,且C动态库通过Python2.5及后续版本内置的ctypes模块加载和执行。该模块作为Python的外部函数库,提供与C语言兼容的数据类型,并允许调用DLL或共享库中的函数。因此,ctypes常被用来在纯Python代码中封装(wrap)外部动态库。

若代码运行于Pypy环境,则需使用cffi接口调用C程序:

  1. from cffi import FFI
  2. CFuncObj, ffiBuilder = None, FFI()
  3. def LoadCExtLib():
  4. try:
  5. global CFuncObj
  6. ffiBuilder.cdef('''
  7. unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]);
  8. unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]);
  9. ''')
  10. CFuncObj = ffiBuilder.dlopen('CalcLines.dll')
  11. except Exception: #不捕获系统退出(SystemExit)和键盘中断(KeyboardInterrupt)异常
  12. pass
  13. def CalcLines(fileType, line, isBlockComment):
  14. try:
  15. bCmmtArr = ffiBuilder.new('unsigned char[2]', isBlockComment)
  16. if fileType is 'ch': #is(同一性运算符)判断对象标识(id)是否相同,较==更快
  17. lineType = CFuncObj.CalcLinesCh(line, bCmmtArr)
  18. else:
  19. lineType = CFuncObj.CalcLinesPy(line, bCmmtArr)
  20. isBlockComment[0] = True if bCmmtArr[0] else False
  21. isBlockComment[1] = True if bCmmtArr[1] else False
  22. #不能采用以下写法,否则本函数返回后isBlockComment列表内容仍为原值
  23. #isBlockComment = [True if i else False for i in bCmmtArr]
  24. except Exception, e:
  25. #print e
  26. if fileType is 'ch':
  27. lineType = CalcLinesCh(line, isBlockComment)
  28. else:
  29. lineType = CalcLinesPy(line, isBlockComment)
  30. return lineType

cffi用法类似ctypes,但允许直接加载C文件来调用里面的函数(在解释过程中自动编译)。此处为求统一,仍使用加载动态库的方式。

  1. def SafeDiv(dividend, divisor):
  2. if divisor: return float(dividend)/divisor
  3. elif dividend: return -1
  4. else: return 0
  5. gProcFileNum = 0
  6. def CountFileLines(filePath, isRawReport=True, isShortName=False):
  7. fileExt = os.path.splitext(filePath)
  8. if fileExt[1] == '.c' or fileExt[1] == '.h':
  9. fileType = 'ch'
  10. elif fileExt[1] == '.py': #==(比较运算符)判断对象值(value)是否相同
  11. fileType = 'py'
  12. else:
  13. return
  14. global gProcFileNum; gProcFileNum += 1
  15. sys.stderr.write('%d files processed...\r'%gProcFileNum)
  16. isBlockComment = [False]*2 #或定义为全局变量,以保存上次值
  17. lineCountInfo = [0]*5 #[代码总行数, 代码行数, 注释行数, 空白行数, 注释率]
  18. with open(filePath, 'r') as file:
  19. for line in file:
  20. lineType = CalcLines(fileType, line.strip(), isBlockComment)
  21. lineCountInfo[0] += 1
  22. if lineType == 0: lineCountInfo[3] += 1
  23. elif lineType == 1: lineCountInfo[1] += 1
  24. elif lineType == 2: lineCountInfo[2] += 1
  25. elif lineType == 3: lineCountInfo[1] += 1; lineCountInfo[2] += 1
  26. else:
  27. assert False, 'Unexpected lineType: %d(0~3)!' %lineType
  28. if isRawReport:
  29. global rawCountInfo
  30. rawCountInfo[:-1] = [x+y for x,y in zip(rawCountInfo[:-1], lineCountInfo[:-1])]
  31. rawCountInfo[-1] += 1
  32. elif isShortName:
  33. lineCountInfo[4] = SafeDiv(lineCountInfo[2], lineCountInfo[2]+lineCountInfo[1])
  34. detailCountInfo.append([os.path.basename(filePath), lineCountInfo])
  35. else:
  36. lineCountInfo[4] = SafeDiv(lineCountInfo[2], lineCountInfo[2]+lineCountInfo[1])
  37. detailCountInfo.append([filePath, lineCountInfo])

注意"%d files processed..."进度提示。因无法判知输出是否通过命令行重定向至文件(sys.stdout不变,sys.argv不含">out"),该进度提示将换行写入输出文件内。假定代码文件数目为N,输出文件内将含N行进度信息。目前只能利用重定向缺省只影响标准输出的特点,将进度信息由标准错误输出至控制台;同时增加-o选项,以显式地区分标准输出和文件写入,降低使用者重定向的可能性。

此外,调用CalcLines()函数时通过strip()方法剔除文件行首尾的空白字符。因此,CalcLinesCh()和CalcLinesPy()内无需行结束符判断分支。

  1. SORT_ORDER = (lambda x:x[0], False)
  2. def SetSortArg(sortArg=None):
  3. global SORT_ORDER
  4. if not sortArg:
  5. return
  6. if any(s in sortArg for s in ('file', '0')): #条件宽松些
  7. #if sortArg in ('rfile', 'file', 'r0', '0'):
  8. keyFunc = lambda x:x[1][0]
  9. elif any(s in sortArg for s in ('code', '1')):
  10. keyFunc = lambda x:x[1][1]
  11. elif any(s in sortArg for s in ('cmmt', '2')):
  12. keyFunc = lambda x:x[1][2]
  13. elif any(s in sortArg for s in ('blan', '3')):
  14. keyFunc = lambda x:x[1][3]
  15. elif any(s in sortArg for s in ('ctpr', '4')):
  16. keyFunc = lambda x:x[1][4]
  17. elif any(s in sortArg for s in ('name', '5')):
  18. keyFunc = lambda x:x[0]
  19. else: #因argparse内已限制排序参数范围,此处也可用assert
  20. print >>sys.stderr, 'Unsupported sort order(%s)!' %sortArg
  21. return
  22. isReverse = sortArg[0]=='r' #False:升序(ascending); True:降序(decending)
  23. SORT_ORDER = (keyFunc, isReverse)
  24. def ReportCounterInfo(isRawReport=True, stream=sys.stdout):
  25. #代码注释率 = 注释行 / (注释行+有效代码行)
  26. print >>stream, 'FileLines CodeLines CommentLines BlankLines CommentPercent %s'\
  27. %(not isRawReport and 'FileName' or '')
  28. if isRawReport:
  29. print >>stream, '%-11d%-11d%-14d%-12d%-16.2f<Total:%d Code Files>' %(rawCountInfo[0],\
  30. rawCountInfo[1], rawCountInfo[2], rawCountInfo[3], \
  31. SafeDiv(rawCountInfo[2], rawCountInfo[2]+rawCountInfo[1]), rawCountInfo[4])
  32. return
  33. total = [0, 0, 0, 0]
  34. #对detailCountInfo排序。缺省按第一列元素(文件名)升序排序,以提高输出可读性。
  35. detailCountInfo.sort(key=SORT_ORDER[0], reverse=SORT_ORDER[1])
  36. for item in detailCountInfo:
  37. print >>stream, '%-11d%-11d%-14d%-12d%-16.2f%s' %(item[1][0], item[1][1], item[1][2], \
  38. item[1][3], item[1][4], item[0])
  39. total[0] += item[1][0]; total[1] += item[1][1]
  40. total[2] += item[1][2]; total[3] += item[1][3]
  41. print >>stream, '-' * 90 #输出90个负号(minus)或连字号(hyphen)
  42. print >>stream, '%-11d%-11d%-14d%-12d%-16.2f<Total:%d Code Files>' \
  43. %(total[0], total[1], total[2], total[3], \
  44. SafeDiv(total[2], total[2]+total[1]), len(detailCountInfo))

ReportCounterInfo()输出统计报告。注意,详细报告输出前,会根据指定的排序规则对输出内容排序。此外,空白行术语由EmptyLines改为BlankLines。前者表示该行除行结束符外不含任何其他字符,后者表示该行只包含空白字符(空格、制表符和行结束符等)。

为支持同时统计多个目录和(或)文件,使用ParseTargetList()解析目录-文件混合列表,将其元素分别存入目录和文件列表:

  1. def ParseTargetList(targetList):
  2. fileList, dirList = [], []
  3. if targetList == []:
  4. targetList.append(os.getcwd())
  5. for item in targetList:
  6. if os.path.isfile(item):
  7. fileList.append(os.path.abspath(item))
  8. elif os.path.isdir(item):
  9. dirList.append(os.path.abspath(item))
  10. else:
  11. print >>sys.stderr, "'%s' is neither a file nor a directory!" %item
  12. return [fileList, dirList]

LineCounter()函数基于目录和文件列表进行统计:

  1. def CountDir(dirList, isKeep=False, isRawReport=True, isShortName=False):
  2. for dir in dirList:
  3. if isKeep:
  4. for file in os.listdir(dir):
  5. CountFileLines(os.path.join(dir, file), isRawReport, isShortName)
  6. else:
  7. for root, dirs, files in os.walk(dir):
  8. for file in files:
  9. CountFileLines(os.path.join(root, file), isRawReport, isShortName)
  10. def CountFile(fileList, isRawReport=True, isShortName=False):
  11. for file in fileList:
  12. CountFileLines(file, isRawReport, isShortName)
  13. def LineCounter(isKeep=False, isRawReport=True, isShortName=False, targetList=[]):
  14. fileList, dirList = ParseTargetList(targetList)
  15. if fileList != []:
  16. CountFile(fileList, isRawReport, isShortName)
  17. if dirList != []:
  18. CountDir(dirList, isKeep, isRawReport, isShortName)

然后,添加命令行解析处理:

  1. import argparse
  2. def ParseCmdArgs(argv=sys.argv):
  3. parser = argparse.ArgumentParser(usage='%(prog)s [options] target',
  4. description='Count lines in code files.')
  5. parser.add_argument('target', nargs='*',
  6. help='space-separated list of directories AND/OR files')
  7. parser.add_argument('-k', '--keep', action='store_true',
  8. help='do not walk down subdirectories')
  9. parser.add_argument('-d', '--detail', action='store_true',
  10. help='report counting result in detail')
  11. parser.add_argument('-b', '--basename', action='store_true',
  12. help='do not show file\'s full path')
  13. ## sortWords = ['0', '1', '2', '3', '4', '5', 'file', 'code', 'cmmt', 'blan', 'ctpr', 'name']
  14. ## parser.add_argument('-s', '--sort',
  15. ## choices=[x+y for x in ['','r'] for y in sortWords],
  16. ## help='sort order: {0,1,2,3,4,5} or {file,code,cmmt,blan,ctpr,name},' \
  17. ## "prefix 'r' means sorting in reverse order")
  18. parser.add_argument('-s', '--sort',
  19. help='sort order: {0,1,2,3,4,5} or {file,code,cmmt,blan,ctpr,name}, ' \
  20. "prefix 'r' means sorting in reverse order")
  21. parser.add_argument('-o', '--out',
  22. help='save counting result in OUT')
  23. parser.add_argument('-c', '--cache', action='store_true',
  24. help='use cache to count faster(unreliable when files are modified)')
  25. parser.add_argument('-v', '--version', action='version',
  26. version='%(prog)s 3.0 by xywang')
  27. args = parser.parse_args()
  28. return (args.keep, args.detail, args.basename, args.sort, args.out, args.cache, args.target)

注意ParseCmdArgs()函数中增加的-s选项。该选项指定输出排序方式,并由r前缀指定升序还是降序。例如,-s 0-s file表示输出按文件行数升序排列,-s r0-s rfile表示输出按文件行数降序排列。

-c缓存选项最适用于改变输出排序规则时。为支持该选项,使用Json模块持久化统计报告:

  1. CACHE_FILE = 'Counter.dump'
  2. CACHE_DUMPER, CACHE_GEN = None, None
  3. from json import dump, JSONDecoder
  4. def CounterDump(data):
  5. global CACHE_DUMPER
  6. if CACHE_DUMPER == None:
  7. CACHE_DUMPER = open(CACHE_FILE, 'w')
  8. dump(data, CACHE_DUMPER)
  9. def ParseJson(jsonData):
  10. endPos = 0
  11. while True:
  12. jsonData = jsonData[endPos:].lstrip()
  13. try:
  14. pyObj, endPos = JSONDecoder().raw_decode(jsonData)
  15. yield pyObj
  16. except ValueError:
  17. break
  18. def CounterLoad():
  19. global CACHE_GEN
  20. if CACHE_GEN == None:
  21. CACHE_GEN = ParseJson(open(CACHE_FILE, 'r').read())
  22. try:
  23. return next(CACHE_GEN)
  24. except StopIteration, e:
  25. return []
  26. def shouldUseCache(keep, detail, basename, cache, target):
  27. if not cache: #未指定启用缓存
  28. return False
  29. try:
  30. (_keep, _detail, _basename, _target) = CounterLoad()
  31. except (IOError, EOFError, ValueError): #缓存文件不存在或内容为空或不合法
  32. return False
  33. if keep == _keep and detail == _detail and basename == _basename \
  34. and sorted(target) == sorted(_target):
  35. return True
  36. else:
  37. return False

注意,json持久化会涉及字符编码问题。例如,当源文件名包含gbk编码的中文字符时,文件名写入detailCountInfo前应通过unicode(os.path.basename(filePath), 'gbk')转换为Unicode,否则dump时会报错。幸好,只有测试用的源码文件才可能包含中文字符。因此,通常不用考虑编码问题。

此时,可调用以上函数统计代码并输出报告:

  1. def main():
  2. global gIsStdout, rawCountInfo, detailCountInfo
  3. (keep, detail, basename, sort, out, cache, target) = ParseCmdArgs()
  4. stream = sys.stdout if not out else open(out, 'w')
  5. SetSortArg(sort); LoadCExtLib()
  6. cacheUsed = shouldUseCache(keep, detail, basename, cache, target)
  7. if cacheUsed:
  8. try:
  9. (rawCountInfo, detailCountInfo) = CounterLoad()
  10. except (EOFError, ValueError), e: #不太可能出现
  11. print >>sys.stderr, 'Unexpected Cache Corruption(%s), Try Counting Directly.'%e
  12. LineCounter(keep, not detail, basename, target)
  13. else:
  14. LineCounter(keep, not detail, basename, target)
  15. ReportCounterInfo(not detail, stream)
  16. CounterDump((keep, detail, basename, target))
  17. CounterDump((rawCountInfo, detailCountInfo))

为测量行数统计工具的运行效率,还可添加如下计时代码:

  1. if __name__ == '__main__':
  2. from time import clock
  3. startTime = clock()
  4. main()
  5. endTime = clock()
  6. print >>sys.stderr, 'Time Elasped: %.2f sec.' %(endTime-startTime)

为避免cProfile开销,此处使用time.clock()测量耗时。

1.2 代码优化

CalcLinesCh()和CalcLinesPy()除len()函数外并未使用其他Python库函数,因此很容易改写为C实现。其C语言版本实现最初如下:

  1. #include <stdio.h>
  2. #include <string.h>
  3. #define TRUE 1
  4. #define FALSE 0
  5. unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]) {
  6. unsigned int lineType = 0;
  7. unsigned int lineLen = strlen(line);
  8. if(!lineLen)
  9. return lineType;
  10. char *expandLine = calloc(lineLen + 1/*\n*/, 1);
  11. if(NULL == expandLine)
  12. return lineType;
  13. memmove(expandLine, line, lineLen);
  14. expandLine[lineLen] = '\n'; //添加一个字符防止iChar+1时越界
  15. unsigned int iChar = 0;
  16. unsigned char isLineComment = FALSE;
  17. while(iChar < lineLen) {
  18. if(expandLine[iChar] == ' ' || expandLine[iChar] == '\t') { //空白字符
  19. iChar += 1; continue;
  20. }
  21. else if(expandLine[iChar] == '/' && expandLine[iChar+1] == '/') { //行注释
  22. isLineComment = TRUE;
  23. lineType |= 2; iChar += 1; //跳过'/'
  24. }
  25. else if(expandLine[iChar] == '/' && expandLine[iChar+1] == '*') { //块注释开始符
  26. isBlockComment[0] = TRUE;
  27. lineType |= 2; iChar += 1;
  28. }
  29. else if(expandLine[iChar] == '*' && expandLine[iChar+1] == '/') { //块注释结束符
  30. isBlockComment[0] = FALSE;
  31. lineType |= 2; iChar += 1;
  32. }
  33. else {
  34. if(isLineComment || isBlockComment[0])
  35. lineType |= 2;
  36. else
  37. lineType |= 1;
  38. }
  39. iChar += 1;
  40. }
  41. free(expandLine);
  42. return lineType; //Bitmap:0空行,1代码,2注释,3代码和注释
  43. }
  44. unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]) {
  45. //isBlockComment[single quotes, double quotes]
  46. unsigned int lineType = 0;
  47. unsigned int lineLen = strlen(line);
  48. if(!lineLen)
  49. return lineType;
  50. char *expandLine = calloc(lineLen + 2/*\n\n*/, 1);
  51. if(NULL == expandLine)
  52. return lineType;
  53. memmove(expandLine, line, lineLen);
  54. //添加两个字符防止iChar+2时越界
  55. expandLine[lineLen] = '\n'; expandLine[lineLen+1] = '\n';
  56. unsigned int iChar = 0;
  57. unsigned char isLineComment = FALSE;
  58. while(iChar < lineLen) {
  59. if(expandLine[iChar] == ' ' || expandLine[iChar] == '\t') { //空白字符
  60. iChar += 1; continue;
  61. }
  62. else if(expandLine[iChar] == '#') { //行注释
  63. isLineComment = TRUE;
  64. lineType |= 2;
  65. }
  66. else if(expandLine[iChar] == '\'' && expandLine[iChar+1] == '\''
  67. && expandLine[iChar+2] == '\'') { //单引号块注释
  68. if(isBlockComment[0] || isBlockComment[1])
  69. isBlockComment[0] = FALSE;
  70. else
  71. isBlockComment[0] = TRUE;
  72. lineType |= 2; iChar += 2;
  73. }
  74. else if(expandLine[iChar] == '"' && expandLine[iChar+1] == '"'
  75. && expandLine[iChar+2] == '"') { //双引号块注释
  76. if(isBlockComment[0] || isBlockComment[1])
  77. isBlockComment[1] = FALSE;
  78. else
  79. isBlockComment[1] = TRUE;
  80. lineType |= 2; iChar += 2;
  81. }
  82. else {
  83. if(isLineComment || isBlockComment[0] || isBlockComment[1])
  84. lineType |= 2;
  85. else
  86. lineType |= 1;
  87. }
  88. iChar += 1;
  89. }
  90. free(expandLine);
  91. return lineType; //Bitmap:0空行,1代码,2注释,3代码和注释
  92. }

这种实现最接近原来的Python版本,但还能进一步优化,如下:

  1. #define TRUE 1
  2. #define FALSE 0
  3. unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]) {
  4. unsigned int lineType = 0;
  5. unsigned int iChar = 0;
  6. unsigned char isLineComment = FALSE;
  7. while(line[iChar] != '\0') {
  8. if(line[iChar] == ' ' || line[iChar] == '\t') { //空白字符
  9. iChar += 1; continue;
  10. }
  11. else if(line[iChar] == '/' && line[iChar+1] == '/') { //行注释
  12. isLineComment = TRUE;
  13. lineType |= 2; iChar += 1; //跳过'/'
  14. }
  15. else if(line[iChar] == '/' && line[iChar+1] == '*') { //块注释开始符
  16. isBlockComment[0] = TRUE;
  17. lineType |= 2; iChar += 1;
  18. }
  19. else if(line[iChar] == '*' && line[iChar+1] == '/') { //块注释结束符
  20. isBlockComment[0] = FALSE;
  21. lineType |= 2; iChar += 1;
  22. }
  23. else {
  24. if(isLineComment || isBlockComment[0])
  25. lineType |= 2;
  26. else
  27. lineType |= 1;
  28. }
  29. iChar += 1;
  30. }
  31. return lineType; //Bitmap:0空行,1代码,2注释,3代码和注释
  32. }
  33. unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]) {
  34. //isBlockComment[single quotes, double quotes]
  35. unsigned int lineType = 0;
  36. unsigned int iChar = 0;
  37. unsigned char isLineComment = FALSE;
  38. while(line[iChar] != '\0') {
  39. if(line[iChar] == ' ' || line[iChar] == '\t') { //空白字符
  40. iChar += 1; continue;
  41. }
  42. else if(line[iChar] == '#') { //行注释
  43. isLineComment = TRUE;
  44. lineType |= 2;
  45. }
  46. else if(line[iChar] == '\'' && line[iChar+1] == '\''
  47. && line[iChar+2] == '\'') { //单引号块注释
  48. if(isBlockComment[0] || isBlockComment[1])
  49. isBlockComment[0] = FALSE;
  50. else
  51. isBlockComment[0] = TRUE;
  52. lineType |= 2; iChar += 2;
  53. }
  54. else if(line[iChar] == '"' && line[iChar+1] == '"'
  55. && line[iChar+2] == '"') { //双引号块注释
  56. if(isBlockComment[0] || isBlockComment[1])
  57. isBlockComment[1] = FALSE;
  58. else
  59. isBlockComment[1] = TRUE;
  60. lineType |= 2; iChar += 2;
  61. }
  62. else {
  63. if(isLineComment || isBlockComment[0] || isBlockComment[1])
  64. lineType |= 2;
  65. else
  66. lineType |= 1;
  67. }
  68. iChar += 1;
  69. }
  70. return lineType; //Bitmap:0空行,1代码,2注释,3代码和注释
  71. }

优化后的版本利用&&运算符短路特性,因此不必考虑越界问题,从而避免动态内存的分配和释放。

作者的Windows系统最初未安装Microsoft VC++工具,因此使用已安装的MinGW开发环境编译dll文件。将上述C代码保存为CalcLines.c,编译命令如下:

  1. gcc -shared -o CalcLines.dll CalcLines.c

注意,MinGW中编译dll和编译so的命令相同。-shared选项指明创建共享库,在Windows中为dll文件,在Unix系统中为so文件。

其间,作者还尝试其他C扩展工具,如PyInline。在http://pyinline.sourceforge.net/下载压缩包,解压后拷贝目录PyInline-0.03至Lib\site-packages下。在命令提示符窗口中进入该目录,执行python setup.py install安装PyInline
执行示例时提示BuildError: error: Unable to find vcvarsall.bat。查阅网络资料,作者下载Microsoft Visual C++ Compiler for Python 2.7并安装。然而,实践后发现PyInline非常难用,于是作罢。

由于对MinGW编译效果存疑,作者最终决定安装VS2008 Express Edition。之所以选择2008版本,是考虑到CPython2.7的Windows版本基于VS2008的运行时(runtime)库。安装后,在C:\Program Files\Microsoft Visual Studio 9.0\VC\bin目录可找到cl.exe(编译器)和link.exe(链接器)。按照网络教程设置环境变量后,即可在Visual Studio 2008 Command Prompt命令提示符中编译和链接程序。输入cl /helpcl -help可查看编译器选项说明。

将CalcLines.c编译为动态链接库前,还需要对函数头添加_declspec(dllexport),以指明这是从dll导出的函数:

  1. _declspec(dllexport) unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]) {...
  2. _declspec(dllexport) unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]) {...

否则Python程序加载动态库后,会提示找不到相应的C函数。

添加函数导出标记后,执行如下命令编译源代码:

  1. cl /Ox /Ot /Wall /LD /FeCalcLines.dll CalcLines.c

其中,/Ox选项表示使用最大优化,/Ot选项表示代码速度优先。/LD表示创建动态链接库,/Fe指明动态库名称。

动态库文件可用UPX压缩。由MinGW编译的dll文件,UPX压缩前后分别为13KB和11KB;而VS2008编译过的dll文件,UPX压缩前后分别为41KB和20KB。经测两者速度相当。考虑到动态库体积,后文仅使用MinGW编译的dll文件。

使用C扩展的动态链接库,代码统计工具在CPython2.7环境下可获得极大的速度提升。相对而言,Pypy因为本身加速效果显著,动态库的性能提升反而不太明显。此外,当待统计文件数目较少时,也可不使用dll文件(此时将启用Python版本的算法);当文件数目较多时,dll文件会显著提高统计速度。详细的评测数据参见第二节。

作者使用的Pypy版本为5.1,可从官网下载Win32安装包。该安装包默认包含cffi1.6,后者的使用可参考《Python学习入门手册以及CFFI》CFFI官方文档。安装Pypy5.1后,在命令提示符窗口输入pypy可查看pypy和cffi版本信息:

  1. E:\PyTest>pypy
  2. Python 2.7.10 (b0a649e90b66, Apr 28 2016, 13:11:00)
  3. [PyPy 5.1.1 with MSC v.1500 32 bit] on win32
  4. Type "help", "copyright", "credits" or "license" for more information.
  5. >>>> import cffi
  6. >>>> cffi.__version__
  7. '1.6.0'

若要CPLineCounter在未安装Python环境的主机上运行,应先将CPython版本的代码转换为exe并压缩后,连同压缩后的dll文件一并发布。使用者可将其放入同一个目录,再将该目录加入PATH环境变量,即可在Windows命令提示符窗口中运行CPLineCounter。例如:

  1. D:\pytest>CPLineCounter -d lctest -s code
  2. FileLines CodeLines CommentLines BlankLines CommentPercent FileName
  3. 6 3 4 0 0.57 D:\pytest\lctest\hard.c
  4. 27 7 15 5 0.68 D:\pytest\lctest\file27_code7_cmmt15_blank5.py
  5. 33 19 15 4 0.44 D:\pytest\lctest\line.c
  6. 44 34 3 7 0.08 D:\pytest\lctest\test.c
  7. 44 34 3 7 0.08 D:\pytest\lctest\subdir\test.c
  8. 243 162 26 60 0.14 D:\pytest\lctest\subdir\CLineCounter.py
  9. ------------------------------------------------------------------------------------------
  10. 397 259 66 83 0.20 <Total:6 Code Files>
  11. Time Elasped: 0.04 sec.

二. 精度与性能评测

为检验CPLineCounter统计精度和性能,作者从网上下载几款常见的行数统计工具,即cloc1.64(10.9MB)、linecount3.7(451KB)、SourceCounter3.4(8.34MB)和SourceCount_1.0(644KB)。

首先测试统计精度。以line.c为目标代码,上述工具的统计输出如下表所示("-"表示该工具未直接提供该统计项):

统计工具 文件行数 代码行数 注释行数 空白行数 注释率
CPLineCounter 33 19 15 4 0.44
cloc - 19 10 4 -
linecount 33 20 15 4 0.43
SourceCounter 33 20 15 4 -
SourceCount 34 20 9 5 0.44

经人工检验,CPLineCounter的统计结果准确无误。linecount和SourceCounter统计也较为可靠。

然后,统计82个源代码文件,上述工具的统计输出如下表所示:

统计工具 文件行数 代码行数 注释行数 空白行数 注释率
CPLineCounter 140872 93736 32106 16938 0.26
cloc - 93738 30197 16937 -
linecount 140872 93712 32109 16938 0.26
SourceCounter 140876 93736 32106 16942 -
SourceCount 140952 93743 30800 16409 0.22

通常,文件总行数和空行数统计规则简单,不易出错。因此,选取这两项统计重合度最高的工具作为基准,即CPLineCounter和linecount。同时,对于代码行数和注释行数,CPLineCounter和SourceCounter的统计结果重合。根据统计重合度,有理由认为CPLineCounter的统计精度最高。

最后,测试统计性能。在作者的Windows XP主机(Pentium G630 2.7GHz主频2GB内存)上,统计5857个C源代码文件,总行数接近千万级。上述工具的性能表现如下表所示。表中仅显示总计项,实际上仍统计单个文件的行数信息。注意,测试时linecount要勾选"目录统计时包含同名文件",cloc要添加--skip-uniqueness--by-file选项。

统计工具 文件行数 代码行数 注释行数 空白行数 注释率 耗时
CPLineCounter 9963248 9110051 647947 357009 0.07 29~281秒
cloc* - 9099677 490177 353897 - 845秒
linecount 9963248 9122500 640614 357009 0.07 900秒
SourceCounter 9963282 9110053 647936 357041 - 105秒
SourceCount - - - - - 超1小时(卡死)

其中,CPLineCounter的性能因运行场景而异,统计耗时少则29秒,多则281秒。。需要注意的是,cloc仅统计出5733个文件。

以条形图展示上述工具的统计性能,如下所示:
      此处输入图片的描述
图中"Opt-c"表示CPLineCounter以-c选项运行,"CPython2.7+ctypes(O)"表示以CPython2.7环境运行附带旧DLL库的CPLineCounter,"Pypy5.1+cffi1.6(N)"表示以Pypy5.1环境运行附带新DLL库的CPLineCounter,以此类推。

由于CPLineCounter并非纯粹的CPU密集型程序,因此DLL库算法本身的优化并未带来性能的显著提升(对比旧DLL库和新DLL库)。对比之下,Pypy内置JIT(即时编译)解释器,可从整体上极大地提升Python脚本的运行速度,加速效果甚至可与C匹敌。此外,性能测试数据会受到目标代码、CPU架构、预热、缓存、后台程序等多方面因素影响,因此不同工具或组合的性能表现可能与作者给出的数据略有出入。

综合而言,CPLineCounter统计速度最快且结果可靠,软件体积也小(exe1.3MB,dll11KB)。SourceCounter统计结果比较可靠,速度较快,且内置项目管理信息。cloc文件数目统计误差大,linecount代码行统计误差大,两者速度较慢。但cloc可配置项丰富,并且可自行编译以压缩体积。SourceCount统计速度最慢,结果也不太可靠。

了解Python并行计算的读者也可修改CPLineCounter源码实现,加入多进程处理,压满多核处理器;还可尝试多线程,以改善IO性能。以下截取CountFileLines()函数的部分line_profiler结果:

  1. E:\PyTest>kernprof -l -v CPLineCounter.py source -d > out.txt
  2. 140872 93736 32106 16938 0.26 <Total:82 Code Files>
  3. Wrote profile results to CPLineCounter.py.lprof
  4. Timer unit: 2.79365e-07 s
  5. Total time: 5.81981 s
  6. File: CPLineCounter.py
  7. Function: CountFileLines at line 143
  8. Line # Hits Time Per Hit % Time Line Contents
  9. ==============================================================
  10. 143 @profile
  11. 144 def CountFileLines(filePath, isRawReport=True, isShortName=False):
  12. ... ... ... ... ... ... ... ...
  13. 162 82 7083200 86380.5 34.0 with open(filePath, 'r') as file:
  14. 163 140954 1851877 13.1 8.9 for line in file:
  15. 164 140872 6437774 45.7 30.9 lineType = CalcLines(fileType, line.strip(), isBlockComment)
  16. 165 140872 1761864 12.5 8.5 lineCountInfo[0] += 1
  17. 166 140872 1662583 11.8 8.0 if lineType == 0: lineCountInfo[3] += 1
  18. 167 123934 1499176 12.1 7.2 elif lineType == 1: lineCountInfo[1] += 1
  19. 168 32106 406931 12.7 2.0 elif lineType == 2: lineCountInfo[2] += 1
  20. 169 1908 27634 14.5 0.1 elif lineType == 3: lineCountInfo[1] += 1; lineCountInfo[2] += 1
  21. ... ... ... ... ... ... ... ...

line_profiler可用pip install line_profiler安装。在待评估函数前添加装饰器@profile后,运行kernprof命令,将给出被装饰函数中每行代码所耗费的时间。-l选项指明逐行分析,-v选项则指明执行后屏显计时信息。Hits(执行次数)或Time(执行时间)值较大的代码行具有较大的优化空间。

由line_profiler结果可见,该函数偏向CPU密集型(75~80行占用该函数56.7%的耗时)。然而考虑到目录遍历等操作,很可能整体程序为IO密集型。因此,选用多进程还是多线程加速还需要测试验证。最简单地,可将73~80行(即读文件和统计行数)均改为C实现。其他部分要么为IO密集型要么使用Python库,用C语言改写事倍功半。

最后,若仅仅统计代码行数,Linux或Mac系统中可使用如下shell命令:

  1. find ./codeDir -name "*.c" -or -name "*.h" | xargs wc -l #除空行外的总行数
  2. find ./codeDir -name "*.c" -or -name "*.h" | xargs wc -l #各文件行数及总和
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注