命令行下的樹形打印
最近在處理代碼分析問題時,需要將代碼的作用域按照樹形結構輸出。問題的原型大概是下邊這個樣子的。

圖中給了一個簡化的代碼片段,該代碼片段包含5個作用域:全局作用域0、函數fun作用域1、if語句作用域2、else語句作用域3和函數main作用域4。代碼作用域有個顯著的特點就是具有樹形結構,全局作用域作為樹根,函數作用域則是其子節點,而局部作用域則是函數作用域的子節點,以此類推。如果要在命令行下輸出作用域的組織結構,則是目錄樹的形式。
將樹進行命令行打印並非一件難事,使用的是樹的遞歸深度遍歷的思想。代碼片段如下:
樹形打印作用域
root:子樹根
blk:縮進次數
*/
void printTree (Scope*root, int blk)
{
for( int i= 0;i<blk;i++)printf( " "); // 縮進
printf( " |—<%d>\n ", root->id); // 打印"|—<id>"形式
for ( int i = 0; i < root->children.size(); ++i) // 遍歷子節點
{
printTree(root->children[i],blk+ 1); // 打印子樹,累加縮進次數
}
}
函數printTree是對樹root的一次深度前序遍歷,處理每個樹根時,先執行當前子樹的縮進,然后打印字符串"|—<id>\n"——節點ID,最后順序遍歷子節點,遞歸執行打印函數。不過這里需要注意的是,打印每個節點時,需要對子節點增加一次縮進。因此使用blk參數記錄每顆子樹對應的縮進個數,樹根的縮進次數初始化為0。打印效果如下:

仔細觀察,我們發現打印的效果仍有缺陷。所有的節點縮進都是正常的,但是節點4輸出的位置跨越了節點2和3的行,沒辦法使得節點4打印的連接符號和父節點0連接起來!我們希望輸出得更好一點。修改一下代碼,我們每次執行縮進的時候都輸出字符"|",這樣就能聯系所有的子節點了。
但是這樣做,仍不夠完美,我們換棵樹看看效果。

如圖所示,比如節點2或者9的子樹輸出時,冒出很多多余的連接符號,這樣影響對樹形結構的觀察,我們應該把這些多余的連接符去掉!
但是單純依靠純粹的遞歸輸出,只能選擇是否輸出連接符兩種手段。如果要通過對樹的一次遍歷將所有連接符輸出正確的話,我們是不能預測接下來輸出的節點縮進個數的(有可能是兄弟節點,有可能是子節點,也有可能是前邊祖先節點……)。
然而事情總有解決的辦法,每個節點輸出時是准確知道自己的縮進個數的。這里再次利用遞歸思想思考這個問題:假設前邊的節點輸出時都准確的打印了連接符,當前節點輸出之前,只要將自身和前一個兄弟節點連接起來即可!
舉例來說,當節點8輸出時,假設節點0-7都正常輸出,沒有產生多余的連接符。我們只需要將節點8和節點2(前一個兄弟節點)之間用連接符連接起來即可,其他節點同理!

這樣就有一個新的問題產生了,在節點8輸出之前,前邊的內容已經打印出來了,如何“返回去”重新打印這列連接符呢?答案是——光標移動!
命令行下,字符的打印都是在當前光標之后輸出的,光標的位置決定了當前打印字符的位置!如果在打印節點8之前,我們控制光標向上移動(共移動5次——節點8和節點2相差的行數),每移動一次打印一個連接符。不過這里需要注意,每次打印連接符后,光標位置向右移動一列,我們繼續移動之前需要將光標列坐標恢復(減1),否則實際打印的將是一個階梯狀的連線!

既然控制光標可以完成我們的目標,具體該如何操作呢?當然,Windows的API提供了解決辦法。
BOOL
WINAPI
SetConsoleCursorPosition(
__in HANDLE hConsoleOutput,
__in COORD dwCursorPosition
);
Windows的API函數SetConsoleCursorPosition用來設定光標的位置,參數hConsoleOutput表示輸出流句柄,我們使用GetStdHandle(STD_OUTPUT_HANDLE)獲取。參數dwCursorPosition是個COORD類型,記錄了要設置的光標位置。
SHORT X;
SHORT Y;
} COORD, *PCOORD;
我們封裝一個簡單的函數用於將光標移動到指定位置。
{
HANDLE out_handle=GetStdHandle(STD_OUTPUT_HANDLE);
COORD loc;
loc.X=x;
loc.Y=y;
SetConsoleCursorPosition(out_handle, loc);
}
重新思考剛才的光標移動問題,我們是要在輸出節點信息前向上移動光標,而非將光標移動到一個絕對的坐標,而是相對坐標!為此,我們還需要獲取當前的光標位置,計算出移動到的目的坐標。
Windows的API函數GetConsoleScreenBufferInfo提供了獲取光標位置的方法。
BOOL
WINAPI
GetConsoleScreenBufferInfo(
__in HANDLE hConsoleOutput,
__out PCONSOLE_SCREEN_BUFFER_INFO lpConsoleScreenBufferInfo
);
參數hConsoleOutput還是輸出流句柄,參數lpConsoleScreenBufferInfo是PCONSOLE_SCREEN_BUFFER_INFO類型。
COORD dwSize;
COORD dwCursorPosition;
WORD wAttributes;
SMALL_RECT srWindow;
COORD dwMaximumWindowSize;
} CONSOLE_SCREEN_BUFFER_INFO, *PCONSOLE_SCREEN_BUFFER_INFO;
PCONSOLE_SCREEN_BUFFER_INFO是結構體類型_CONSOLE_SCREEN_BUFFER_INFO(CONSOLE_SCREEN_BUFFER_INFO)的指針,其成員dwCursorPosition記錄了光標的坐標。於是我們封裝一個光標相對移動的函數。
{
HANDLE out_handle=GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_SCREEN_BUFFER_INFO info;
GetConsoleScreenBufferInfo(out_handle,&info);
COORD loc;
loc.X=info.dwCursorPosition.X+x;
loc.Y=info.dwCursorPosition.Y+y;
SetConsoleCursorPosition(out_handle, loc);
}
有了move函數,就可以完成上述問題連接符的“填充”了。但是有一點需要注意,在輸出連接符的時候,最終改變了光標的原本的位置。我們需要在“填充”前保存光標位置,“填充”后恢復光標位置!為此再封裝兩個函數。
void pushCursor() // 保存光標位置
{
HANDLE out_handle=GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_SCREEN_BUFFER_INFO info;
GetConsoleScreenBufferInfo(out_handle,&info);
x=info.dwCursorPosition.X;
y=info.dwCursorPosition.Y;
}
void popCursor() // 恢復光標位置
{
HANDLE out_handle=GetStdHandle(STD_OUTPUT_HANDLE);
COORD loc;
loc.X=x;
loc.Y=y;
SetConsoleCursorPosition(out_handle, loc);
}
然后我們重新修改剛才的打印代碼。
樹形打印作用域
root:子樹根
blk:縮進次數
y:記錄打印的行數
*/
void printTree(Scope*root, int blk, int& y)
{
for( int i= 0;i<blk;i++)printf( " "); // 縮進
// 記錄打印位置
root->y=y;
// 填充不連續的列
if(root->parent){ // 有父節點
vector<Scope*>& brother=root->parent->children; // 兄弟節點
// 查找節點在兄弟列表的位置
vector<Scope*>::iterator pos=lower_bound(brother.begin(),brother.end(),root,Scope::scope_less());
if(pos!=brother.begin()){ // 不是第一個兄弟
Scope* prev=(*--pos); // 前一個兄弟
int disp=root->y-prev->y- 1; // 求差值
pushCursor(); // 保存光標位置
while(disp--){ // 不停上移動光標,輸出|
move( 0,- 1); // 上移
printf( " | "); // 打印|
move(- 1, 0); // 左移回復光標位置
}
popCursor(); // 恢復光標位置
}
}
printf( " |—<%d>\n ", root->id);
for ( int i = 0; i < root->children.size(); ++i)
{
printTree(root->children[i],blk+ 1,++y); // 累加縮進次數和行數
}
}
我們添加一個引用參數y記錄打印了多少行,每次打印節點時將行數記錄到節點內部。然后按照樹節點的數據結構查找前一個兄弟節點。
{
struct scope_less
{
bool operator()(Scope*left,Scope*right){
return left->id<right->id; // 按照id確定節點大小
}
};
int id;
Scope*parent; // 記錄父親節點
vector<Scope*> children; // 子作用域
int y; // 行位置
};
找到前一個兄弟節點后,計算當前打印節點行數和兄弟節點行數的差值,然后“填充”連接符即可。輸出結構如下:

如圖所示的輸出結果,已經達到了我們最初的要求。
以上是Windows提供的光標控制的API函數,那么在Linux下,我們該如何處理光標移動呢?其實C語言的printf函數使用ANSI控制碼實現了對光標控制、修改顏色(Windows下使用API函數SetConsoleTextAttribute設定顏色,GetConsoleScreenBufferInfo得到的CONSOLE_SCREEN_BUFFER_INFO::wAttributes獲取顏色)等功能!比如: printf("\033[s");用於保存光標位置,printf("\033[u");用於恢復光標位置。printf("\033[1A");將光標上移一行,printf("\033[1D");將光標左移一列。具體控制格式參考下表:
QUOTE:
字背景顏色范圍: 40-- 49 字顏色: 30-- 39
40: 黑 30: 黑
41: 紅 31: 紅
42: 綠 32: 綠
43: 黃 33: 黃
44: 藍 34: 藍
45: 紫 35: 紫
46: 深綠 36: 深綠
47: 白色 37: 白色
ANSI控制碼:
QUOTE:
\ 033[0m 關閉所有屬性
\ 033[1m 設置高亮度
\ 033[4m 下划線
\ 033[5m 閃爍
\ 033[7m 反顯
\ 033[8m 消隱
\ 033[30m -- \ 033[37m 設置前景色
\ 033[40m -- \ 033[47m 設置背景色
\ 033[nA 光標上移n行
\ 033[nB 光標下移n行
\ 033[nC 光標右移n行
\ 033[nD 光標左移n行
\ 033[y ; xH 設置光標位置
\ 033[2J 清屏
\ 033[K 清除從光標到行尾的內容
\ 033[s 保存光標位置
\ 033[u 恢復光標位置
\ 033[?25l 隱藏光標
\ 033[?25h 顯示光標
綜上所述,我們可以通過操作系統提供的光標控制功能實現命令行下樹形結構的完美打印,希望本文對你有所幫助!
