練習22:棧、作用域和全局
譯者:飛龍
許多人在開始編程時,對“作用域”這個概念都不是很清楚。起初它來源于系統棧的使用方式(在之前提到過一些),以及它用于臨時變量儲存的方式。這個練習中,我們會通過學習站數據結構如何工作來了解作用域,然后再來看看現代C語言處理作用域的方式。
這個練習的真正目的是了解一些比較麻煩的東西在C中如何存儲。當一個人沒有掌握作用域的概念時,它幾乎也不能理解變量在哪里被創建,存在以及銷毀。一旦你知道了這些,作用域的概念會變得易于理解。
這個練習需要如下三個文件:
ex22.h
用于創建一些外部變量和一些函數的頭文件。
ex22.c
它并不像通常一樣,是包含main
的源文件,而是含有一些ex22.h
中聲明的函數和變量,并且會變成ex22.o
。
ex22_main.c
main
函數實際所在的文件,它會包含另外兩個文件,并演示了它們包含的東西以及其它作用域概念。
ex22.h 和 ex22.c
你的第一步是創建你自己的ex22.h
頭文件,其中定義了所需的函數和“導出”變量。
#ifndef _ex22_h
#define _ex22_h
// makes THE_SIZE in ex22.c available to other .c files
extern int THE_SIZE;
// gets and sets an internal static variable in ex22.c
int get_age();
void set_age(int age);
// updates a static variable that's inside update_ratio
double update_ratio(double ratio);
void print_size();
#endif
最重要的事情是extern int THE_SIZE
的用法,我將會在你創建完ex22.c
之后解釋它:
#include <stdio.h>
#include "ex22.h"
#include "dbg.h"
int THE_SIZE = 1000;
static int THE_AGE = 37;
int get_age()
{
return THE_AGE;
}
void set_age(int age)
{
THE_AGE = age;
}
double update_ratio(double new_ratio)
{
static double ratio = 1.0;
double old_ratio = ratio;
ratio = new_ratio;
return old_ratio;
}
void print_size()
{
log_info("I think size is: %d", THE_SIZE);
}
這兩個文件引入了一些新的變量儲存方式:
extern
這個關鍵詞告訴編譯器“這個變量已存在,但是他在別的‘外部區域’里”。通常它的意思是一個.c
文件要用到另一個.c
文件中定義的變量。這種情況下,我們可以說ex2.c
中的THE_SIZE
變量能變為ex22_main.c
訪問到。
static
(文件)
這個關鍵詞某種意義上是extern
的反義詞,意思是這個變量只能在當前的.c
文件中使用,程序的其它部分不可訪問。要記住文件級別的static
(比如這里的THE_AGE
)和其它位置不同。
static
(函數)
如果你使用static
在函數中聲明變量,它和文件中的static
定義類似,但是只能夠在該函數中訪問。它是一種創建某個函數的持續狀態的方法,但事實上它跟梢用于現代的C語言,因為它們很難和線程一起使用。
在上面的兩個文件中,你需要理解如下幾個變量和函數:
THE_SIZE
這個你使用extern
聲明的變量將會在ex22_main.c
中用到。
get_age
和set_age
它們用于操作靜態變量THE_AGE
,并通過函數將其暴露給程序的其它部分。你不能夠直接訪問到THE_AGE
,但是這些函數可以。
update_ratio
它生成新的ratio
值并返回舊的值。它使用了函數級的靜態變量ratio
來跟蹤ratio
當前的值。
print_size
打印出ex22.c
所認為的THE_SIZE
的當前值。
ex22_main.c
一旦你寫完了上面那些文件,你可以接著編程main
函數,它會使用所有上面的文件并且演示了一些更多的作用域轉換:
#include "ex22.h"
#include "dbg.h"
const char *MY_NAME = "Zed A. Shaw";
void scope_demo(int count)
{
log_info("count is: %d", count);
if(count > 10) {
int count = 100; // BAD! BUGS!
log_info("count in this scope is %d", count);
}
log_info("count is at exit: %d", count);
count = 3000;
log_info("count after assign: %d", count);
}
int main(int argc, char *argv[])
{
// test out THE_AGE accessors
log_info("My name: %s, age: %d", MY_NAME, get_age());
set_age(100);
log_info("My age is now: %d", get_age());
// test out THE_SIZE extern
log_info("THE_SIZE is: %d", THE_SIZE);
print_size();
THE_SIZE = 9;
log_info("THE SIZE is now: %d", THE_SIZE);
print_size();
// test the ratio function static
log_info("Ratio at first: %f", update_ratio(2.0));
log_info("Ratio again: %f", update_ratio(10.0));
log_info("Ratio once more: %f", update_ratio(300.0));
// test the scope demo
int count = 4;
scope_demo(count);
scope_demo(count * 20);
log_info("count after calling scope_demo: %d", count);
return 0;
}
我會把這個文件逐行拆分,你應該能夠找到我提到的每個變量在哪里定義。
ex22_main.c:4
使用了const
來創建常量,它可用于替代define
來創建常量。
ex22_main.c:6
一個簡單的函數,演示了函數中更多的作用域問題。
ex22_main.c:8
在函數頂端打印出count
的值。
ex22_main.c:10
if
語句會開啟一個新的作用域區塊,并且在其中創建了另一個count
變量。這個版本的count
變量是一個全新的變量。if
語句就好像開啟了一個新的“迷你函數”。
ex22_main.c:11
count
對于當前區塊是局部變量,實際上不同于函數參數列表中的參數。
ex22_main.c:13
將它打印出來,所以你可以在這里看到100,并不是傳給scope_demo
的參數。
ex22_main.c:16
這里是最難懂得部分。你在兩部分都有count
變量,一個數函數參數,另一個是if
語句中。if
語句創建了新的代碼塊,所以11行的count
并不影響同名的參數。這一行將其打印出來,你會看到它打印了參數的值而不是100。
ex22_main.c:18-20
之后我將count
參數設為3000并且打印出來,這里演示了你也可以修改函數參數的值,但并不會影響變量的調用者版本。
確保你瀏覽了整個函數,但是不要認為你已經十分了解作用娛樂。如果你在一個代碼塊中(比如if
或while
語句)創建了一些變量,這些變量是全新的變量,并且只在這個代碼塊中存在。這是至關重要的東西,也是許多bug的來源。我要強調你應該在這里花一些時間。
ex22_main.c
的剩余部分通過操作和打印變量演示了它們的全部。
ex22_main.c:26
打印出MY_NAME
的當前值,并且使用get_age
讀寫器從ex22.c
獲取THE_AGE
。
ex22_main.c:27-30
使用了ex22.c
中的set_age
來修改并打印THE_AGE
。
ex22_main.c:33-39
接下來我對ex22.c
中的THE_SIZE
做了相同的事情,但這一次我直接訪問了它,并且同時演示了它實際上在那個文件中已經修改了,還使用print_size
打印了它。
ex22_main.c:42-44
展示了update_ratio
中的ratio
在兩次函數調用中如何保持了它的值。
ex22_main.c:46-51
最后運行scope_demo
,你可以在實例中觀察到作用域。要注意到的關鍵點是,count
局部變量在調用后保持不變。你將它像一個變量一樣傳入函數,它一定不會發生改變。要想達到目的你需要我們的老朋友指針。如果你將指向count
的指針傳入函數,那么函數就會持有它的地址并且能夠改變它。
上面解釋了這些文件中所發生的事情,但是你應該跟蹤它們,并且確保在你學習的過程中明白了每個變量都在什么位置。
你會看到什么
這次我想讓你手動構建這兩個文件,而不是使用你的Makefile
。于是你可以看到它們實際上如何被編譯器放到一起。這是你應該做的事情,并且你應該看到如下輸出:
$ cc -Wall -g -DNDEBUG -c -o ex22.o ex22.c
$ cc -Wall -g -DNDEBUG ex22_main.c ex22.o -o ex22_main
$ ./ex22_main
[INFO] (ex22_main.c:26) My name: Zed A. Shaw, age: 37
[INFO] (ex22_main.c:30) My age is now: 100
[INFO] (ex22_main.c:33) THE_SIZE is: 1000
[INFO] (ex22.c:32) I think size is: 1000
[INFO] (ex22_main.c:38) THE SIZE is now: 9
[INFO] (ex22.c:32) I think size is: 9
[INFO] (ex22_main.c:42) Ratio at first: 1.000000
[INFO] (ex22_main.c:43) Ratio again: 2.000000
[INFO] (ex22_main.c:44) Ratio once more: 10.000000
[INFO] (ex22_main.c:8) count is: 4
[INFO] (ex22_main.c:16) count is at exit: 4
[INFO] (ex22_main.c:20) count after assign: 3000
[INFO] (ex22_main.c:8) count is: 80
[INFO] (ex22_main.c:13) count in this scope is 100
[INFO] (ex22_main.c:16) count is at exit: 80
[INFO] (ex22_main.c:20) count after assign: 3000
[INFO] (ex22_main.c:51) count after calling scope_demo: 4
確保你跟蹤了每個變量是如何改變的,并且將其匹配到所輸出的那一行。我使用了dbg.h
的log_info
來讓你獲得每個變量打印的具體行號,并且在文件中找到它用于跟蹤。
作用域、棧和Bug
如果你正確完成了這個練習,你會看到有很多不同方式在C代碼中放置變量。你可以使用extern
或者訪問類似get_age
的函數來創建全局。你也可以在任何代碼塊中創建新的變量,它們在退出代碼塊之前會擁有自己的值,并且屏蔽掉外部的變量。你也可以響函數傳遞一個值并且修改它,但是調用者的變量版本不會發生改變。
需要理解的最重要的事情是,這些都可以造成bug。C中在你機器中許多位置放置和訪問變量的能力會讓你對它們所在的位置感到困擾。如果你不知道它們的位置,你就可能不能適當地管理它們。
下面是一些編程C代碼時需要遵循的規則,可以讓你避免與棧相關的bug:
- 不要隱藏某個變量,就像上面
scope_demo
中對count
所做的一樣。這可能會產生一些隱蔽的bug,你認為你改變了某個變量但實際上沒有。 - 避免過多的全局變量,尤其是跨越多個文件。如果必須的話,要使用讀寫器函數,就像
get_age
。這并不適用于常量,因為它們是只讀的。我是說對于THE_SIZE
這種變量,如果你希望別人能夠修改它,就應該使用讀寫器函數。 - 在你不清楚的情況下,應該把它放在堆上。不要依賴于棧的語義,或者指定區域,而是要直接使用
malloc
創建它。 - 不要使用函數級的靜態變量,就像
update_ratio
。它們并不有用,而且當你想要使你的代碼運行在多線程環境時,會有很大的隱患。對于良好的全局變量,它們也非常難于尋找。 - 避免復用函數參數,因為你搞不清楚僅僅想要復用它還是希望修改它的調用者版本。
如何使它崩潰
對于這個練習,崩潰這個程序涉及到嘗試訪問或修改你不能訪問的東西。
- 試著從
ex22_main.c
直接訪問ex22.c
中的你不能訪問變量。例如,你能不能獲取update_ratio
中的ratio
?如果你用一個指針指向它會發生什么? - 移除
ex22.h
的extern
聲明,來觀察會得到什么錯誤或警告。 - 對不同變量添加
static
或者const
限定符,之后嘗試修改它們。
附加題
- 研究“值傳遞”和“引用傳遞”的差異,并且為二者編寫示例。(譯者注:C中沒有引用傳遞,你可以搜索“指針傳遞”。)
- 使用指針來訪問原本不能訪問的變量。
- 使用
Valgrind
來觀察錯誤的訪問是什么樣子。 - 編寫一個遞歸調用并導致棧溢出的函數。如果不知道遞歸函數是什么的話,試著在
scope_demo
底部調用scope_demo
本身,會形成一種循環。 - 重新編寫
Makefile
使之能夠構建這些文件。