Python實現代碼統計工具——終極加速篇

Python實現代碼統計工具——終極加速篇

聲明

本文對於先前系列文章中實現的C/Python代碼統計工具(CPLineCounter),經過C擴展接口重寫核心算法加以優化,並與網上常見的統計工具作對比。實測代表,CPLineCounter在統計精度和性能方面均優於其餘同類統計工具。以千萬行代碼爲例評測性能,CPLineCounter在Cpython和Pypy環境下運行時,比國外統計工具cloc1.64分別快14.5倍和29倍,比國內SourceCounter3.4分別快1.8倍和3.6倍。html

運行測試環境

本文基於Windows系統平臺,運行和測試所涉及的代碼實例。平臺信息以下:python

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

注意,Python不一樣版本間語法存在差別,故文中某些代碼實例須要稍做修改,以便在低版本Python環境中運行。算法

一. 代碼實現與優化

爲避免碎片化,本節將給出完整的實現代碼。注意,本節某些變量或函數定義與先前系列文章中的實現存在細微差別,請注意甄別。shell

1.1 代碼實現

首先,定義兩個存儲統計結果的列表:json

import os, sys
rawCountInfo = [0, 0, 0, 0, 0]
detailCountInfo = []

其中,rawCountInfo存儲粗略的文件總行數信息,列表元素依次爲文件行、代碼行、註釋行和空白行的總數,以及文件數目。detailCountInfo存儲詳細的統計信息,包括單個文件的行數信息和文件名,以及全部文件的行數總和。緩存

如下將給出具體的實現代碼。爲避免大段粘貼代碼,以函數爲片斷簡要描述。網絡

def CalcLinesCh(line, isBlockComment):
    lineType, lineLen = 0, len(line)
    if not lineLen:
        return lineType

    line = line + '\n' #添加一個字符防止iChar+1時越界
    iChar, isLineComment = 0, False
    while iChar < lineLen:
        if line[iChar] == ' ' or line[iChar] == '\t':   #空白字符
            iChar += 1; continue
        elif line[iChar] == '/' and line[iChar+1] == '/': #行註釋
            isLineComment = True
            lineType |= 2; iChar += 1 #跳過'/'
        elif line[iChar] == '/' and line[iChar+1] == '*': #塊註釋開始符
            isBlockComment[0] = True
            lineType |= 2; iChar += 1
        elif line[iChar] == '*' and line[iChar+1] == '/': #塊註釋結束符
            isBlockComment[0] = False
            lineType |= 2; iChar += 1
        else:
            if isLineComment or isBlockComment[0]:
                lineType |= 2
            else:
                lineType |= 1
        iChar += 1

    return lineType   #Bitmap:0空行,1代碼,2註釋,3代碼和註釋

def CalcLinesPy(line, isBlockComment):
    #isBlockComment[single quotes, double quotes]
    lineType, lineLen = 0, len(line)
    if not lineLen:
        return lineType

    line = line + '\n\n' #添加兩個字符防止iChar+2時越界
    iChar, isLineComment = 0, False
    while iChar < lineLen:
        if line[iChar] == ' ' or line[iChar] == '\t':   #空白字符
            iChar += 1; continue
        elif line[iChar] == '#':            #行註釋
            isLineComment = True
            lineType |= 2
        elif line[iChar:iChar+3] == "'''":  #單引號塊註釋
            if isBlockComment[0] or isBlockComment[1]:
                isBlockComment[0] = False
            else:
                isBlockComment[0] = True
            lineType |= 2; iChar += 2
        elif line[iChar:iChar+3] == '"""':  #雙引號塊註釋
            if isBlockComment[0] or isBlockComment[1]:
                isBlockComment[1] = False
            else:
                isBlockComment[1] = True
            lineType |= 2; iChar += 2
        else:
            if isLineComment or isBlockComment[0] or isBlockComment[1]:
                lineType |= 2
            else:
                lineType |= 1
        iChar += 1

    return lineType   #Bitmap:0空行,1代碼,2註釋,3代碼和註釋

CalcLinesCh()和CalcLinesPy()函數分別基於C和Python語法判斷文件行屬性,按代碼、註釋或空行分別統計。多線程

from ctypes import c_uint, c_ubyte, CDLL
CFuncObj = None
def LoadCExtLib():
    try:
        global CFuncObj
        CFuncObj = CDLL('CalcLines.dll')
    except Exception: #不捕獲系統退出(SystemExit)和鍵盤中斷(KeyboardInterrupt)異常
        pass

def CalcLines(fileType, line, isBlockComment):
    try:
        #不可將CDLL('CalcLines.dll')放於本函數內,不然可能嚴重拖慢執行速度
        bCmmtArr = (c_ubyte * len(isBlockComment))(*isBlockComment)
        CFuncObj.CalcLinesCh.restype = c_uint
        if fileType is 'ch': #is(同一性運算符)判斷對象標識(id)是否相同,較==更快
            lineType = CFuncObj.CalcLinesCh(line, bCmmtArr)
        else:
            lineType = CFuncObj.CalcLinesPy(line, bCmmtArr)

        isBlockComment[0] = True if bCmmtArr[0] else False
        isBlockComment[1] = True if bCmmtArr[1] else False
        #不能採用如下寫法,不然本函數返回後isBlockComment列表內容仍爲原值
        #isBlockComment = [True if i else False for i in bCmmtArr]
    except Exception, e:
        #print e
        if fileType is 'ch':
            lineType = CalcLinesCh(line, isBlockComment)
        else:
            lineType = CalcLinesPy(line, isBlockComment)

    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程序:

from cffi import FFI
CFuncObj, ffiBuilder = None, FFI()
def LoadCExtLib():
    try:
        global CFuncObj
        ffiBuilder.cdef('''
        unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]);
        unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]);
        ''')
        CFuncObj = ffiBuilder.dlopen('CalcLines.dll')
    except Exception: #不捕獲系統退出(SystemExit)和鍵盤中斷(KeyboardInterrupt)異常
        pass

def CalcLines(fileType, line, isBlockComment):
    try:
        bCmmtArr = ffiBuilder.new('unsigned char[2]', isBlockComment)
        if fileType is 'ch': #is(同一性運算符)判斷對象標識(id)是否相同,較==更快
            lineType = CFuncObj.CalcLinesCh(line, bCmmtArr)
        else:
            lineType = CFuncObj.CalcLinesPy(line, bCmmtArr)

        isBlockComment[0] = True if bCmmtArr[0] else False
        isBlockComment[1] = True if bCmmtArr[1] else False
        #不能採用如下寫法,不然本函數返回後isBlockComment列表內容仍爲原值
        #isBlockComment = [True if i else False for i in bCmmtArr]
    except Exception, e:
        #print e
        if fileType is 'ch':
            lineType = CalcLinesCh(line, isBlockComment)
        else:
            lineType = CalcLinesPy(line, isBlockComment)

    return lineType

cffi用法相似ctypes,但容許直接加載C文件來調用裏面的函數(在解釋過程當中自動編譯)。此處爲求統一,仍使用加載動態庫的方式。

def SafeDiv(dividend, divisor):
    if divisor: return float(dividend)/divisor
    elif dividend:       return -1
    else:                return 0

gProcFileNum = 0
def CountFileLines(filePath, isRawReport=True, isShortName=False):
    fileExt = os.path.splitext(filePath)
    if fileExt[1] == '.c' or fileExt[1] == '.h':
        fileType = 'ch'
    elif fileExt[1] == '.py': #==(比較運算符)判斷對象值(value)是否相同
        fileType = 'py'
    else:
        return

    global gProcFileNum; gProcFileNum += 1
    sys.stderr.write('%d files processed...\r'%gProcFileNum)

    isBlockComment = [False]*2  #或定義爲全局變量,以保存上次值
    lineCountInfo = [0]*5       #[代碼總行數, 代碼行數, 註釋行數, 空白行數, 註釋率]
    with open(filePath, 'r') as file:
        for line in file:
            lineType = CalcLines(fileType, line.strip(), isBlockComment)
            lineCountInfo[0] += 1
            if   lineType == 0:  lineCountInfo[3] += 1
            elif lineType == 1:  lineCountInfo[1] += 1
            elif lineType == 2:  lineCountInfo[2] += 1
            elif lineType == 3:  lineCountInfo[1] += 1; lineCountInfo[2] += 1
            else:
                assert False, 'Unexpected lineType: %d(0~3)!' %lineType

    if isRawReport:
        global rawCountInfo
        rawCountInfo[:-1] = [x+y for x,y in zip(rawCountInfo[:-1], lineCountInfo[:-1])]
        rawCountInfo[-1] += 1
    elif isShortName:
        lineCountInfo[4] = SafeDiv(lineCountInfo[2], lineCountInfo[2]+lineCountInfo[1])
        detailCountInfo.append([os.path.basename(filePath), lineCountInfo])
    else:
        lineCountInfo[4] = SafeDiv(lineCountInfo[2], lineCountInfo[2]+lineCountInfo[1])
        detailCountInfo.append([filePath, lineCountInfo])

注意"%d files processed..."進度提示。因沒法判知輸出是否經過命令行重定向至文件(sys.stdout不變,sys.argv不含">out"),該進度提示將換行寫入輸出文件內。假定代碼文件數目爲N,輸出文件內將含N行進度信息。目前只能利用重定向缺省隻影響標準輸出的特色,將進度信息由標準錯誤輸出至控制檯;同時增長-o選項,以顯式地區分標準輸出和文件寫入,下降使用者重定向的可能性。

此外,調用CalcLines()函數時經過strip()方法剔除文件行首尾的空白字符。所以,CalcLinesCh()和CalcLinesPy()內無需行結束符判斷分支。

SORT_ORDER = (lambda x:x[0], False)
def SetSortArg(sortArg=None):
    global SORT_ORDER
    if not sortArg:
        return
    if any(s in sortArg for s in ('file', '0')): #條件寬鬆些
    #if sortArg in ('rfile', 'file', 'r0', '0'):
        keyFunc = lambda x:x[1][0]
    elif any(s in sortArg for s in ('code', '1')):
        keyFunc = lambda x:x[1][1]
    elif any(s in sortArg for s in ('cmmt', '2')):
        keyFunc = lambda x:x[1][2]
    elif any(s in sortArg for s in ('blan', '3')):
        keyFunc = lambda x:x[1][3]
    elif any(s in sortArg for s in ('ctpr', '4')):
        keyFunc = lambda x:x[1][4]
    elif any(s in sortArg for s in ('name', '5')):
        keyFunc = lambda x:x[0]
    else: #因argparse內已限制排序參數範圍,此處也可用assert
        print >>sys.stderr, 'Unsupported sort order(%s)!' %sortArg
        return

    isReverse = sortArg[0]=='r' #False:升序(ascending); True:降序(decending)
    SORT_ORDER = (keyFunc, isReverse)

def ReportCounterInfo(isRawReport=True, stream=sys.stdout):
     #代碼註釋率 = 註釋行 / (註釋行+有效代碼行)
    print >>stream, 'FileLines  CodeLines  CommentLines  BlankLines  CommentPercent  %s'\
          %(not isRawReport and 'FileName' or '')

    if isRawReport:
       print >>stream, '%-11d%-11d%-14d%-12d%-16.2f<Total:%d Code Files>' %(rawCountInfo[0],\
             rawCountInfo[1], rawCountInfo[2], rawCountInfo[3], \
             SafeDiv(rawCountInfo[2], rawCountInfo[2]+rawCountInfo[1]), rawCountInfo[4])
       return

    total = [0, 0, 0, 0]
    #對detailCountInfo排序。缺省按第一列元素(文件名)升序排序,以提升輸出可讀性。
    detailCountInfo.sort(key=SORT_ORDER[0], reverse=SORT_ORDER[1])
    for item in detailCountInfo:
        print >>stream, '%-11d%-11d%-14d%-12d%-16.2f%s' %(item[1][0], item[1][1], item[1][2], \
              item[1][3], item[1][4], item[0])
        total[0] += item[1][0]; total[1] += item[1][1]
        total[2] += item[1][2]; total[3] += item[1][3]
    print >>stream, '-' * 90  #輸出90個負號(minus)或連字號(hyphen)
    print >>stream, '%-11d%-11d%-14d%-12d%-16.2f<Total:%d Code Files>' \
          %(total[0], total[1], total[2], total[3], \
          SafeDiv(total[2], total[2]+total[1]), len(detailCountInfo))

ReportCounterInfo()輸出統計報告。注意,詳細報告輸出前,會根據指定的排序規則對輸出內容排序。此外,空白行術語由EmptyLines改成BlankLines。前者表示該行除行結束符外不含任何其餘字符,後者表示該行只包含空白字符(空格、製表符和行結束符等)。

爲支持同時統計多個目錄和(或)文件,使用ParseTargetList()解析目錄-文件混合列表,將其元素分別存入目錄和文件列表:

def ParseTargetList(targetList):
    fileList, dirList = [], []
    if targetList == []:
        targetList.append(os.getcwd())
    for item in targetList:
        if os.path.isfile(item):
            fileList.append(os.path.abspath(item))
        elif os.path.isdir(item):
            dirList.append(os.path.abspath(item))
        else:
            print >>sys.stderr, "'%s' is neither a file nor a directory!" %item
    return [fileList, dirList]

LineCounter()函數基於目錄和文件列表進行統計:

def CountDir(dirList, isKeep=False, isRawReport=True, isShortName=False):
    for dir in dirList:
        if isKeep:
            for file in os.listdir(dir):
                CountFileLines(os.path.join(dir, file), isRawReport, isShortName)
        else:
            for root, dirs, files in os.walk(dir):
               for file in files:
                  CountFileLines(os.path.join(root, file), isRawReport, isShortName)

def CountFile(fileList, isRawReport=True, isShortName=False):
    for file in fileList:
        CountFileLines(file, isRawReport, isShortName)

def LineCounter(isKeep=False, isRawReport=True, isShortName=False, targetList=[]):
    fileList, dirList = ParseTargetList(targetList)
    if fileList != []:
        CountFile(fileList, isRawReport, isShortName)
    if dirList != []:
        CountDir(dirList, isKeep, isRawReport, isShortName)

而後,添加命令行解析處理:

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

    args = parser.parse_args()
    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模塊持久化統計報告:

CACHE_FILE = 'Counter.dump'
CACHE_DUMPER, CACHE_GEN = None, None

from json import dump, JSONDecoder
def CounterDump(data):
    global CACHE_DUMPER
    if CACHE_DUMPER == None:
        CACHE_DUMPER = open(CACHE_FILE, 'w')
    dump(data, CACHE_DUMPER)

def ParseJson(jsonData):
    endPos = 0
    while True:
        jsonData = jsonData[endPos:].lstrip()
        try:
            pyObj, endPos = JSONDecoder().raw_decode(jsonData)
            yield pyObj
        except ValueError:
            break

def CounterLoad():
    global CACHE_GEN
    if CACHE_GEN == None:
        CACHE_GEN = ParseJson(open(CACHE_FILE, 'r').read())

    try:
        return next(CACHE_GEN)
    except StopIteration, e:
        return []

def shouldUseCache(keep, detail, basename, cache, target):
    if not cache:  #未指定啓用緩存
        return False

    try:
        (_keep, _detail, _basename, _target) = CounterLoad()
    except (IOError, EOFError, ValueError): #緩存文件不存在或內容爲空或不合法
        return False

    if keep == _keep and detail == _detail and basename == _basename \
       and sorted(target) == sorted(_target):
        return True
    else:
        return False

注意,json持久化會涉及字符編碼問題。例如,當源文件名包含gbk編碼的中文字符時,文件名寫入detailCountInfo前應經過unicode(os.path.basename(filePath), 'gbk')轉換爲Unicode,不然dump時會報錯。幸虧,只有測試用的源碼文件纔可能包含中文字符。所以,一般不用考慮編碼問題。

此時,可調用以上函數統計代碼並輸出報告:

def main():
    global gIsStdout, rawCountInfo, detailCountInfo
    (keep, detail, basename, sort, out, cache, target) = ParseCmdArgs()
    stream = sys.stdout if not out else open(out, 'w')
    SetSortArg(sort); LoadCExtLib()
    cacheUsed = shouldUseCache(keep, detail, basename, cache, target)
    if cacheUsed:
        try:
            (rawCountInfo, detailCountInfo) = CounterLoad()
        except (EOFError, ValueError), e: #不太可能出現
            print >>sys.stderr, 'Unexpected Cache Corruption(%s), Try Counting Directly.'%e
            LineCounter(keep, not detail, basename, target)
    else:
       LineCounter(keep, not detail, basename, target)

    ReportCounterInfo(not detail, stream)
    CounterDump((keep, detail, basename, target))
    CounterDump((rawCountInfo, detailCountInfo))

爲測量行數統計工具的運行效率,還可添加以下計時代碼:

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

爲避免cProfile開銷,此處使用time.clock()測量耗時。

1.2 代碼優化

CalcLinesCh()和CalcLinesPy()除len()函數外並未使用其餘Python庫函數,所以很容易改寫爲C實現。其C語言版本實現最初以下:

#include <stdio.h>
#include <string.h>
#define TRUE    1
#define FALSE   0

unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]) {
    unsigned int lineType = 0;
    unsigned int lineLen = strlen(line);
    if(!lineLen)
        return lineType;

    char *expandLine = calloc(lineLen + 1/*\n*/, 1);
    if(NULL == expandLine)
        return lineType;
    memmove(expandLine, line, lineLen);
    expandLine[lineLen] = '\n'; //添加一個字符防止iChar+1時越界

    unsigned int iChar = 0;
    unsigned char isLineComment = FALSE;
    while(iChar < lineLen) {
        if(expandLine[iChar] == ' ' || expandLine[iChar] == '\t') {  //空白字符
            iChar += 1; continue;
        }
        else if(expandLine[iChar] == '/' && expandLine[iChar+1] == '/') { //行註釋
            isLineComment = TRUE;
            lineType |= 2; iChar += 1; //跳過'/'
        }
        else if(expandLine[iChar] == '/' && expandLine[iChar+1] == '*') { //塊註釋開始符
            isBlockComment[0] = TRUE;
            lineType |= 2; iChar += 1;
        }
        else if(expandLine[iChar] == '*' && expandLine[iChar+1] == '/') { //塊註釋結束符
            isBlockComment[0] = FALSE;
            lineType |= 2; iChar += 1;
        }
        else {
            if(isLineComment || isBlockComment[0])
                lineType |= 2;
            else
                lineType |= 1;
        }
        iChar += 1;
    }

    free(expandLine);
    return lineType;   //Bitmap:0空行,1代碼,2註釋,3代碼和註釋
}

unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]) {
    //isBlockComment[single quotes, double quotes]
    unsigned int lineType = 0;
    unsigned int lineLen = strlen(line);
    if(!lineLen)
        return lineType;

    char *expandLine = calloc(lineLen + 2/*\n\n*/, 1);
    if(NULL == expandLine)
        return lineType;
    memmove(expandLine, line, lineLen);
    //添加兩個字符防止iChar+2時越界
    expandLine[lineLen] = '\n'; expandLine[lineLen+1] = '\n'; 

    unsigned int iChar = 0;
    unsigned char isLineComment = FALSE;
    while(iChar < lineLen) {
        if(expandLine[iChar] == ' ' || expandLine[iChar] == '\t') {  //空白字符
            iChar += 1; continue;
        }
        else if(expandLine[iChar] == '#') { //行註釋
            isLineComment = TRUE;
            lineType |= 2;
        }
        else if(expandLine[iChar] == '\'' && expandLine[iChar+1] == '\''
             && expandLine[iChar+2] == '\'') { //單引號塊註釋
            if(isBlockComment[0] || isBlockComment[1])
                isBlockComment[0] = FALSE;
            else
                isBlockComment[0] = TRUE;
            lineType |= 2; iChar += 2;
        }
        else if(expandLine[iChar] == '"' && expandLine[iChar+1] == '"'
             && expandLine[iChar+2] == '"') { //雙引號塊註釋
            if(isBlockComment[0] || isBlockComment[1])
                isBlockComment[1] = FALSE;
            else
                isBlockComment[1] = TRUE;
            lineType |= 2; iChar += 2;
        }
        else {
            if(isLineComment || isBlockComment[0] || isBlockComment[1])
                lineType |= 2;
            else
                lineType |= 1;
        }
        iChar += 1;
    }

    free(expandLine);
    return lineType;   //Bitmap:0空行,1代碼,2註釋,3代碼和註釋
}

這種實現最接近原來的Python版本,但還能進一步優化,以下:

#define TRUE    1
#define FALSE   0
unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]) {
    unsigned int lineType = 0;

    unsigned int iChar = 0;
    unsigned char isLineComment = FALSE;
    while(line[iChar] != '\0') {
        if(line[iChar] == ' ' || line[iChar] == '\t') {  //空白字符
            iChar += 1; continue;
        }
        else if(line[iChar] == '/' && line[iChar+1] == '/') { //行註釋
            isLineComment = TRUE;
            lineType |= 2; iChar += 1; //跳過'/'
        }
        else if(line[iChar] == '/' && line[iChar+1] == '*') { //塊註釋開始符
            isBlockComment[0] = TRUE;
            lineType |= 2; iChar += 1;
        }
        else if(line[iChar] == '*' && line[iChar+1] == '/') { //塊註釋結束符
            isBlockComment[0] = FALSE;
            lineType |= 2; iChar += 1;
        }
        else {
            if(isLineComment || isBlockComment[0])
                lineType |= 2;
            else
                lineType |= 1;
        }
        iChar += 1;
    }

    return lineType;   //Bitmap:0空行,1代碼,2註釋,3代碼和註釋
}

unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]) {
    //isBlockComment[single quotes, double quotes]
    unsigned int lineType = 0;

    unsigned int iChar = 0;
    unsigned char isLineComment = FALSE;
    while(line[iChar] != '\0') {
        if(line[iChar] == ' ' || line[iChar] == '\t') {  //空白字符
            iChar += 1; continue;
        }
        else if(line[iChar] == '#') { //行註釋
            isLineComment = TRUE;
            lineType |= 2;
        }
        else if(line[iChar] == '\'' && line[iChar+1] == '\''
             && line[iChar+2] == '\'') { //單引號塊註釋
            if(isBlockComment[0] || isBlockComment[1])
                isBlockComment[0] = FALSE;
            else
                isBlockComment[0] = TRUE;
            lineType |= 2; iChar += 2;
        }
        else if(line[iChar] == '"' && line[iChar+1] == '"'
             && line[iChar+2] == '"') { //雙引號塊註釋
            if(isBlockComment[0] || isBlockComment[1])
                isBlockComment[1] = FALSE;
            else
                isBlockComment[1] = TRUE;
            lineType |= 2; iChar += 2;
        }
        else {
            if(isLineComment || isBlockComment[0] || isBlockComment[1])
                lineType |= 2;
            else
                lineType |= 1;
        }
        iChar += 1;
    }

    return lineType;   //Bitmap:0空行,1代碼,2註釋,3代碼和註釋
}

優化後的版本利用&&運算符短路特性,所以沒必要考慮越界問題,從而避免動態內存的分配和釋放。

做者的Windows系統最初未安裝Microsoft VC++工具,所以使用已安裝的MinGW開發環境編譯dll文件。將上述C代碼保存爲CalcLines.c,編譯命令以下:

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導出的函數:

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

不然Python程序加載動態庫後,會提示找不到相應的C函數。

添加函數導出標記後,執行以下命令編譯源代碼:

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版本信息:

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

若要CPLineCounter在未安裝Python環境的主機上運行,應先將CPython版本的代碼轉換爲exe並壓縮後,連同壓縮後的dll文件一併發佈。使用者可將其放入同一個目錄,再將該目錄加入PATH環境變量,便可在Windows命令提示符窗口中運行CPLineCounter。例如:

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

二. 精度與性能評測

爲檢驗CPLineCounter統計精度和性能,做者從網上下載幾款常見的行數統計工具,即cloc1.64(10.9MB)、linecount3.7(451KB)、SourceCounter3.4(8.34MB)和SourceCount_1.0(644KB)。

首先測試統計精度。以line.c爲目標代碼,上述工具的統計輸出以下表所示("-"表示該工具未直接提供該統計項):
    
經人工檢驗,CPLineCounter的統計結果準確無誤。linecount和SourceCounter統計也較爲可靠。

而後,統計82個源代碼文件,上述工具的統計輸出以下表所示:
    
一般,文件總行數和空行數統計規則簡單,不易出錯。所以,選取這兩項統計重合度最高的工具做爲基準,即CPLineCounter和linecount。同時,對於代碼行數和註釋行數,CPLineCounter和SourceCounter的統計結果重合。根據統計重合度,有理由認爲CPLineCounter的統計精度最高。

最後,測試統計性能。在做者的Windows XP主機(Pentium G630 2.7GHz主頻2GB內存)上,統計5857個C源代碼文件,總行數接近千萬級。上述工具的性能表現以下表所示。表中僅顯示總計項,實際上仍統計單個文件的行數信息。注意,測試時linecount要勾選"目錄統計時包含同名文件",cloc要添加--skip-uniqueness--by-file選項。
    
其中,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結果:

E:\PyTest>kernprof -l -v CPLineCounter.py source -d > out.txt
140872     93736      32106         16938       0.26            <Total:82 Code Files>
Wrote profile results to CPLineCounter.py.lprof
Timer unit: 2.79365e-07 s

Total time: 5.81981 s
File: CPLineCounter.py
Function: CountFileLines at line 143

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   143                                           @profile
   144                                           def CountFileLines(filePath, isRawReport=True, isShortName=False):
... ... ... ... ... ... ... ...
   162        82      7083200  86380.5     34.0      with open(filePath, 'r') as file:
   163    140954      1851877     13.1      8.9          for line in file:
   164    140872      6437774     45.7     30.9              lineType = CalcLines(fileType, line.strip(), isBlockComment)
   165    140872      1761864     12.5      8.5              lineCountInfo[0] += 1
   166    140872      1662583     11.8      8.0              if   lineType == 0:  lineCountInfo[3] += 1
   167    123934      1499176     12.1      7.2              elif lineType == 1:  lineCountInfo[1] += 1
   168     32106       406931     12.7      2.0              elif lineType == 2:  lineCountInfo[2] += 1
   169      1908        27634     14.5      0.1              elif lineType == 3:  lineCountInfo[1] += 1; lineCountInfo[2] += 1
... ... ... ... ... ... ... ...

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命令:

find ./codeDir -name "*.c" -or -name "*.h" | xargs wc -l  #除空行外的總行數
find ./codeDir -name "*.c" -or -name "*.h" | xargs wc -l  #各文件行數及總和
相關文章
相關標籤/搜索