2022-08-20 原《每周读书》系列更名为《枫影夜读》
上一次写每周读书已经是 13 年 8 月份了,东野圭吾的《流星之绊》,转眼已过去半年了,慨叹时光飞逝什么的虽然老套,却是事实。刚开始工作的时候,说起我写《每周读书》,leader 怀疑地说你能每个礼拜读完一本书?直到今天,由于工作的关系,不仅是每周读书没有每周读完一本书,就是写作、吉他都很少去触碰了。这不是什么好的现象,尤其是 13 年年底,转到广州部门之后,这里的工作时间比以前要再长一些,就更体会到什么叫做“没有时间”了。
最近看《极简欧洲史》和《世界简史》,以这两本书的相对广袤的时间视角去看,这世上多数人都在过着一样平凡而单调的生活,而且其实不是自己主动去思考的结果,多数都是随波逐流罢了。我自己当然不想随波逐流,但是固有的限制太大,也不过是在这些限制之中努力去寻找差异罢了。
与其望着似水流年自怨自艾,还不如给点实际行动出来。但是对我而言,最大的阻碍大概便是自制啊。在广州的生活虽然有点日夜颠倒(其实比起去年的广州已经要好上很多,但还是日夜颠倒),但如果我自制得了,那么每天晚上下班回家,洗澡便睡,第二天起来便可以多出些时间来自己做些其他的事情了,还有午休的时间,饭后的休息时间诸如此类。谈何容易。
罢了,这些牢骚便到此为止吧。这几天看了东野圭吾的《盛夏的方程式》,这部小说是 11 年出版的,中文版是 12 年。东野后期的作品其实真心没什么看头了,《放学后》让我第一次认识东野圭吾,《白夜行》、《幻夜》和《嫌疑人X的献身》都属于巅峰之作,令人大为赞叹,到后来这些年,《红手指》、《毒笑小说》一类作品,实在食之无味了,弃之亦不可惜。
《盛夏的方程式》其实还是算有些看点的,只是不如巅峰作品一样紧凑扣动人心。
再看《极简欧洲史》。以前对欧洲的认识是分散的,割裂的,没有一个完整的思路去把所有的事件和碎片串联起来,这部《极简欧洲史》,以简练通俗的文笔,将欧洲史整个梳理了一遍。
首先该书把欧洲史大致分为古典时期、中世纪和现代,以这个时间轴讲述了欧洲最重要的希腊罗马文化、基督教文化和日尔曼文化这三大元素在欧洲大陆上的冲突和并存。
之后,在这种大背景下,又讲述了欧洲的君主和民主,语言的发展史,等与中国大相径庭的文化,正是欧洲这种自古君主受制于民的文化,才能自发地产生现代民主。而这样看来民主也不过是一种制度罢了。
看完这本书,我觉得最大的收获有几点:
在中世纪欧洲的国家实际上并没有非常明显的分界。古希腊时期只要是城邦组成,后来罗马帝国时期实现了欧洲真正意义上的统一,但是罗马灭亡之后,欧洲就长期出于分裂状态,各种满族入侵欧洲大陆,出现了大量的小国,神奇的是这些小国的君主可以随意穿越,英国的国王可以从法国王室里面找个人过来当。这也跟君主本身权力没有太强有关。
教皇。教皇本身是掌管教会的。尽管基督教很早就被耶稣创立,但是知道四世纪成为罗马帝国国教之后才慢慢兴盛起来,直到整个欧洲大陆,人人都是基督教徒。教会出现以后,便拥有教会自己管辖的封地及收入,所以教皇实际上统治的是整个欧洲大陆所有的基督教徒,比起一个小国的国王来说,管辖的地域要更加广泛。国王是由教皇来加冕的,但是教皇也是脆弱的,需要国王提供保护。这两大势力长期以来竞争合作,互不相让,但是从来没有真正意义上地分出过胜负。也算是挺神奇的文化现象了。现在的教皇依然拥有自己的封地,梵蒂冈。
以上是最近读过的两本书,比较推荐《极简欧洲史》,篇幅不长,内容却挺丰富,可以从中一窥欧洲历史。
今天有同事问我之前写的那篇 iOS 常见 Crash 及解决方案 里面粘贴的 GLibC 关于 memcpy 的代码怎么理解,然后我囧了一下,当时就是随手一 copy,其实没理解透,于是花了点时间看了一下,学了不少东西,写篇博客记录一下。这里真得感谢一下 @raincai 同学的提醒。之前我粘贴的代码如下:
#define BYTE_COPY_FWD(dst_bp, src_bp, nbytes) \
do { \
int __d0; \
asm volatile(/* Clear the direction flag, so copying goes forward. */ \
"cld\n" \
/* Copy bytes. */ \
"rep\n" \
"movsb" : \
"=D" (dst_bp), "=S" (src_bp), "=c" (__d0) : \
"0" (dst_bp), "1" (src_bp), "2" (nbytes) : \
"memory"); \
} while (0)
其实上面这段代码有点问题,整理一下应该是这样:
#define BYTE_COPY_FWD(dst_bp, src_bp, nbytes) \
do {
__asm__ __volatile__ (/* Clear the direction flag, so copying goes forward. */ \
"cld\n" \
/* Copy bytes. */ \
"rep\n" \
"movsb"
:"=D" (dst_bp), "=S" (src_bp), "=c" (__d0) \
:"0" (dst_bp), "1" (src_bp), "2" (nbytes) \
:"memory");
} while (0)
我们一步步来解,看到已经理解的直接跳过就是了。
linux内核代码很多宏都要加上这个,主要是为了是为了防止被调用的时候,复杂语句有些没被执行到。
举个栗子:
#define SOMETHING()\
fun1();\
fun2();
这个宏是为了能执行到 fun1 和 fun2,但是如果你调用这个宏的时候,加上了条件判断:
if (condition == true)
SOMETHING();
那就悲剧了,预编译的时候,宏定义被代码替换掉,那就是
if (condition == true)
fun1();
fun2();
fun2()就掉到判断的外面去了。所以加上这个是为了保险。
这个其实就是用于在 C 语言内嵌汇编的关键字 asm, 有下划线的是个宏,看源码是这样定义的:
#ifndef __GNUC__
#define __asm__ asm
#endif
volatile
跟 asm 类似,带下划线就是个宏,其实就是 volatile 关键字:
#define __volatile__ volatile
带上这个关键字就是告诉 GCC 不要做优化,要完全保留我写的指令,不要做任何修改。所以这个关键字是可选的。
所以总的来说,在 C 语言里面,内嵌汇编的写法就是
__asm__ ("汇编代码段")
或者
__asm__ __volatile__ (指定操作 + "汇编代码段")
复位方向表标记位 DF,即 DF = 0。DF为 0 则源寄存器地址 ESI/EDI (源寄存器/目标寄存器) 递增,1 则递减。
表示重复,repeat,当 ECX (计数器) > 0 的时候就一直 rep。
就是搬移字串,汇编搬移字串有 movsb 和 movsw 两种,movsb 就是 moving string byte,就是一次搬一个字节,mvsw就是搬移字了
EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP等都是X86汇编语言中CPU上的通用寄存器的名称,是32位的寄存器。如果用C语言来解释,可以把这些寄存器当作变量看待。
EAX 是"累加器"(accumulator), 它是很多加法乘法指令的缺省寄存器。
EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址。
ECX 是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。
EDX 则总是被用来放整数除法产生的余数。
ESI/EDI 分别叫做"源/目标索引寄存器"(source/destination index),因为在很多字符串操作指令中, DS:ESI指向源串,而ES:EDI指向目标串.
EBP 是"基址指针"(BASE POINTER), 它最经常被用作高级语言函数调用的"框架指针"(frame pointer).
OK,接下来是那些冒号,插入C代码中的一个汇编语言代码片断可以分成四部分,以“:”号加以分隔,其一般形式为:
指令部:输出部:输入部:损坏部
=D 这样的语句是对输出部的约束条件:
常用约束条件一览
m, v, o —— 表示内存单元;
r —— 表示任何寄存器;
q —— 表示寄存器eax、ebx、ecx、edx之一;
i, h —— 表示直接操作数;
E, F —— 表示浮点数;
g —— 表示”任意“;
a, b, c, d —— 分表表示要求使用寄存器eax、ebx、ecx和edx;
S, D —— 分别表示要求使用寄存器esi和edi;
I —— 表示常数(0到31)。
所以 "=D" (dst_bp), "=S" (src_bp), "=c" (__d0) 就是把 dst_bp 放进 EDI 寄存器, src_bp 放进 ESI 寄存器, __d0 放进 ECX 寄存器。
:"0" (dst_bp), "1" (src_bp), "2" (nbytes) 这里的 0, 1, 2 不属于上面约束条件的字母,而是数字,数字代表跟输出部的第 0/1/2 个约束条件是同一个寄存器,那就很好理解了,就是说 EDI 寄存器里面将会输入 dst_bp, ESI 会输入 src_bp,最后的 ECX 会输入 nbytes 这个变量。
这里以“memory”为约束条件,表示操作完成后内存中的内容已有改变,如果原来某个寄存器(也许在本次操作中并未用到)的内容来自内存,则现在可能已经不一致。
总的来说就是使用movsb指令来按字节搬运字符串,先设置了 EDI, ESI, ECX 几个寄存器的值, 其中EDI寄存器存放拷贝的目的地址,ESI寄存器存放拷贝的源地址,ECX为需要拷贝的字节数。所以最后汇编执行完之后,EDI中的值会保存到dst_bp中,ESI中的值会保存到src_bp中。
这个函数有几个版本的,上面是汇编版本,下面这个是 C 版本,这个就很好理解了:
do \
{ \
size_t __nbytes = (nbytes); \
while (__nbytes > 0) \
{ \
byte __x = ((byte *) src_bp)[0]; \
src_bp += 1; \
__nbytes -= 1; \
((byte *) dst_bp)[0] = __x; \
dst_bp += 1; \
} \
} while (0)
从日升昌走出来,对面就是和日升昌纠葛一个整个世纪的“蔚泰厚”票号。常谓一山不容二虎,日升昌的除了大掌柜雷履泰之外,二掌柜毛鸿翙也是有才之士。毛鸿翙后来执掌蔚泰厚票号,连“蔚丰厚”、“蔚盛长”、“新泰厚”和“天成享”为“蔚”字五联号,成为当时全国规模最大的票号联盟,后期甚至比日升昌还要昌盛。
当时蔚泰厚的老板侯庆来是平遥西南的介休人氏,其父侯兴域在祖业之上苦心经营多年,给侯家积累了大量财富,单在平遥的商号就有协泰蔚、厚长来、新泰永、新泰义、蔚盛长五家。嘉庆十三年左近,侯兴域去世,不久长子泰来、次子恩来相继去世,于是三子侯庆来便主掌了家业。当时日升昌创立票号,极短时间内汇兑生意做得极为红火,侯庆来看着眼红,自恃家财颇丰却苦于没有一个有才干的经理,迟迟未能介入票号行业。要知道当时晋商经营是两权分离,财东只负责投资和选掌柜,实际经营还得是掌柜来做,侯氏正是有钱缺人。而恰恰在这时候,日升昌两个掌柜的一起内斗,便成了侯氏票号起家的及时雨。
日升昌初创之时雷履泰与毛鸿翙齐心协力,日升昌业务蒸蒸日上,但是时日久了,毛鸿翙不甘位居人下,常有揽权之意。正巧雷履泰身染重病,但仍在大掌柜房休养,于是票号大小事务还是得请大掌柜批示。毛鸿翙便趁机对少东家李箴视进言,让雷履泰回家养病。其时正是道光六年,李大全病故,李箴视年方十六,初掌家业,其为人也是秉性忠厚,朴诚无文,于是便听信毛鸿翙建议,对雷履泰说:“你患病多日,号内不能静养,可且回家休养。”雷履泰不知李箴视心性单纯,还以为话中有话,于是脸上不动声色,却答应着回家去了。
雷履泰回家后细思气极,于是给各个分号写下书信,意欲撤回分号。次日李箴视来探望雷履泰,看到桌上书信,不由大惊,便问雷履泰道:“这是为何?”雷履泰淡淡的说:“票号是你家的,各分庄则是我安的,我召回来不过吩咐给你,没什么意思。”此时李箴视便是再笨也明白雷履泰的意思了,何况他只是经验尚浅,为人却极有见地。当下解释道:“李某请雷掌柜在家静养,真心是为了你早日康复,别无他意,雷掌柜千万不要误会。”李箴视再三解释,雷履泰只是不听。
雷履泰一手创办日升昌,从道光三年至当时不过三年,票号业务未稳,李箴视又是初掌家业,如若没了雷履泰的协助,实不知如何是好,于是李箴视双膝一软,当场给雷履泰下跪。雷履泰心性极高,一句“在下可以受不起”,便任他跪去。
李箴视脾气也是极倔,便道:“雷掌柜不答应,我就不起来。”这一跪就是大半天,直到半夜,雷履泰确信少东家确无异心,便把他扶起来,说:“让我回去,大量不是你的主意,其非毛某乎?”
雷履泰虽答应不撤分号,却也不即刻回票号办事,只是在家呆着。于是李箴视便让人每天送酒席一桌,白银五十两到雷履泰家里,誓要求得雷履泰回来。这时毛鸿翙看到少东家全心倚仗雷履泰,而自己又与雷履泰不和,自觉此地再无容人之处,于是心灰意冷,主动请辞,离开自己供职多年的西裕成颜料庄,自己参与创办的日升昌。
离开日升昌的毛鸿翙,只觉怀才不遇,前途迷茫,不知何去何从。便在这时,酝酿票号多时的侯庆来,成为了毛鸿翙的知遇之主。于是“蔚泰厚”便在毛鸿翙的主持下改组为票号,毛鸿翙也以“蔚泰厚”票号为一身抱负施展之地,誓以“蔚泰厚”与雷履泰一决雌雄。
但是经营票号并不是光有资本和人才就足够的,“蔚泰厚”票号初创之时,虽然业务日渐增长,但与日升昌相比仍差距甚远。而且日升昌的前身西裕成颜料庄本就在全国各地有十多家分号,而侯氏光“蔚泰厚”一家实在难以望其项背。于是侯氏动员旗下数家“蔚丰厚”、“蔚盛长”、“新泰厚”和“天成享”四家绸缎庄,全部改组为票号,毛鸿翙又拉拢日升昌旧日熟人郝名扬、阎永安任票号掌柜,自此侯氏票号渐成规模,五家票号联合被称为“蔚”字五联号。这五个票号每家都在全国各地有数十家分号,合五家之力与日升昌比拼。初时“蔚”字五联号的总资产堪与日升昌持平,后来渐渐地超越了日升昌。
毛鸿翙和雷履泰自此在各地市场相互争斗,直到任何一方最终故去。“蔚”字五联号与其他山西票号的历史命运类似,都在经历了庚子之变,太平天国之后,最终消失在辛亥革命的战乱之中。所谓“革命”,真的不像教科书说的那样和平。
我们到平遥的时候是秋天,秋天的阳光慵懒如猫,摊在墙上。我们沿着北大街一路往南,很快就走到了东西南北大街交汇的地方。
这里人头攒动,四条大街的人流汇在一起,老人小孩,游客团体熙熙攘攘,路边的香草肉热腾腾地冒着蒸汽,街上的各种金字招牌在阳光底下晃晃地闪耀着许多不知真假的流传了千年的名字。我们穿过人流,来到一个人气颇旺的院落门前,抬头一看,嗯,来平遥的目的地到了。
如果说尹吉甫征俨狁是平遥诞生的伊始,那么眼前这座大院——日升昌票号——便是平遥兴衰的见证。自从雷履泰和李大全于道光三年(1823年)创立了日升昌,这家票号就注定了要让平遥在百年之中一跃而成全国的金融中心,又在朝代更迭之中辗转而终究一落千丈。日升昌票号历经道光、咸丰、同治、光绪、宣统五代皇帝和中华民国,鼎盛时期分号遍布汉口、天津、济南、西安、开封、南京等地共四十多处,执全国金融之牛耳。当时各地富商争相仿效日升昌开设票号,而全国五十一家票号就有二十二家在平遥,可以说是日升昌成就了平遥,使其在历史中留下灿烂的一笔。而今再看日升昌旧址,昔日繁华不在,宏伟的院落被熙熙攘攘的游客拥得水泄不通,站在大掌柜房门外,连转个身都困难,真是哭笑不得。
大掌柜房看上去颇为窄小,布置也极简单,跟账房满屋算盘天平笔墨纸砚相比,更像是一间供人沉思的静室。好在因此游客大妈们都对这间小房间不太感兴趣,可以在此驻足多看一会。当年创始人雷履泰便是在此冥思苦想,一边探索一边带领着日升昌一步步走向巅峰。雷履泰也不是凭空就能创造出这么一个惊世骇俗的行业出来,日升昌的成功有个先决条件:晋商的兴盛。
晋商本以经营边防军需物资起家,随后又经营“盐运”,凭着山西南部的盐池,在卖盐的期间积累了大量的财富。后来徽商兴起,逼得晋商把目光从盐运转向对外贸易,在明末通过向后金走私大量军火等物资又重新兴盛起来。到了雷履泰时期已是清朝道光年间,晋商已经遍布天下,雷履泰当时所供职的李大全的“西裕成”颜料庄,除了平遥达薄村本部拥有颇具规模的手工作坊之外,在北京、天津、汉口、重庆等地都有分庄。这就给了雷履泰大展宏图的客观条件:有雄厚的资金,有遍布各地的分庄,有遍布天下的晋商,即广大的市场。
当时晋商在外,往家里捎钱的时候极为不便,大量钱银必须走镖,镖费贵而且并不安全,于是有人便想到把钱交给西裕成分号,由分号掌柜亲笔写信给总号,最后再到平遥总号取钱。起初还只是朋友亲戚相求,并不收取费用。后来同乡觉得这种办法挺好纷纷来投,甚至愿意支付一定的费用。于是雷履泰觉得这是一个商机,便是借鉴史上汇兑的经验,兼营起汇兑业务,初试之下,盈利颇丰。终于道光三年,雷履泰和李大全共同创设了“日升昌”票号,从颜料庄转而经营汇兑生意。
从零开始创设一个票号实属不易,除了雄厚的财力和遍布天下的分号,还要有极好的信誉和极高的人才管理能力。雷履泰在创设“日升昌”之后,业务日渐繁忙,由此推想其他各地的商人托镖局押运银钱一样会有诸多麻烦,于是除了颜料庄原有的分号,又在濟南、西安、開封、成都、重慶、長沙、廈門、廣州、桂林、南昌、蘇州、揚州、上海、鎮江、奉天、南京等地先后设立分号,雷履泰亲自联络晋商,招揽业务,在他的经营下,业务蒸蒸日上,慢慢地不只晋商,外省商人,甚至沿海的米帮,丝帮也通过日升昌进行汇兑,在雷履泰治下,日升昌真正做到“汇通天下”。
道光八年,江苏巡抚陶澍曾上奏曰:
向来山东、山西、河南、陕西等处每年来苏置货,约可到银数百万两,……自上年秋冬至今,各省商贾系汇票往来,并无现银运到。
日升昌道光三年创建,短短五年时间,已经成为江苏商人资金往来的主要手段,也因为汇票这种虚拟信用货币加大了市场流通性,而导致江苏通货膨胀,物价上涨。由此日升昌业务之兴盛可见一斑。
日升昌的成功一是靠着李家雄厚的财富,二是在山西占着晋商商路之中心,占尽地利,三是晋商遍布天下,资金流转的需求极强,最后便是日升昌自身信誉保证,最终催使票号的诞生。这些都还是大背景下的客观条件,在运营票号的时候,前无古人之鉴,要从零开始思索票号的发展路线,设计一套稳妥的密文,培养一帮可靠的伙计,都不是容易的事。所以日升昌除了大掌柜雷履泰,还得有二掌柜毛鸿翙以及其他未入史册的大将方才得以支持。而二掌柜这位奇才也有一段精彩的故事,我们回头再详说之。
且说日升昌兴起之后,山西富商也纷纷效仿,直到咸丰十年,山西票号已经发展到一十七家,光绪中年已遍布全国共四百余家分号。可惜后来太平天国兴起之时,连年战乱导致票号开始衰退,至辛亥革命,山西票号相继倒闭,从此空余大院座座,这一百年间无数个故事被埋进砖缝,在墙上斑斑驳驳,只等着对过往的游人诉说。
山西其实并不是我最想去的地方,古韵盎然的江南水乡,幽僻安逸的世外桃源,黄沙万里的玉门关外,还有咸咸海边的宝岛台湾,都是我顶想去而没去过的地方。这次把山西纳入行程主要还是为了拣一个清净的所在。于是摊开地图,圈点几处,竟一路游上了内蒙。
山西似乎有道不尽看不完的古代建筑,但这一路上最是令人沉浸其中的,还得是平遥古城。现在回忆起平遥的砖瓦与城楼,虽不及凤凰一般具有异族风情,山水烟雨迷迷蒙蒙,但其一砖一瓦之间,一宅一楼之中,却蕴藏着凤凰所没有的历史的故事。在凤凰,看景色,在平遥,我们听故事。
故事从诗经开始:
昔我往矣,黍稷方华。今我来思,雨雪载途。王事多难,不遑启居。岂不怀归?畏此简书。
这几句出自《诗经·小雅·出车》,写的是西周末年,西北俨狁犯境,宣王为中兴周室,命大将尹吉甫北伐猃狁之事,当时尹吉甫驻兵于平遥,修西北二面城墙,被平遥人认为是建城的始祖,而这也是平遥在中国历史记载中的第一次出场。说起西周,为人们所熟识的大约便是开国皇帝文王武王,以及末代皇帝——烽火戏诸侯的周幽王。这宣王便是周幽王的父亲。其时周朝疆域在经过成、康二帝的开拓后已经北至肃慎,南到汉水,东到大海,西至渭河,幅员辽阔。但是其后由于西北戎狄逐渐壮大,国家处于常年征战之中,历经四代皇帝,国力耗尽。直至周宣王时整顿朝政,才使国力有所复兴。当时平遥的位置正处犬戎西周边境,乃军事重镇,于是这道战火中修起的城墙,从公元前八二三年,便默默俯视着这座古城两千八百余年的兴衰起伏。
尹吉甫与平遥的渊源只是传说,除了诗经以外,便是清光绪八年的《平遥县志 ·建置》中的记载:
旧城狭小,东西二面俱低,周宣王 时,大将尹吉甫北伐猃狁,驻兵于此,筑西北二面。
也已距周朝两千余年。今天再到平遥,除了上东太和门破败的尹庙、人迹罕至的点将台及尹吉甫墓等遗迹之外,再无尹吉甫的音容事迹。平遥在中国历史上曾经太繁华太灿烂,以致筑城的祖先被掩在城东一角,匆匆旅客流连在东西南北大街,在文武城隍之庙,在票号钱庄之中,而忘却了这遥远的历史。
我们便不曾去寻尹吉甫的古迹,这座城池可看的历史太多,甚至还来不及一一走过便已离此北上。我们从北门开始,走进拱极门的瓮城。拱极二字出自于《旧唐书·礼仪志二》:
叶台耀以分辉,契编珠而拱极。
拱极即指北极星,平遥的城墙于明洪武三年曾大修过,在旧城墙“九里十八步”的基础上扩建成今日的样子。今天站在城墙上俯瞰这座小城,一座座四合院栉比鳞次,皆为灰色的清水砖墙所砌,望眼过去犹如黄沙万里,天地一线,南北大街之间,市楼倚立之下,男女老少熙熙攘攘,一座丰满生动的古城跃然活于眼底,这是一座仍旧活着的古城。
下了城墙,我们沿着古城北大街南下,随意转入一条小巷以避开汹涌的人流。这里同其他古城景区一样,处处有住宿,家家是客栈,不同的大约是这里的客栈多是四合院土炕房,与凤凰的吊脚楼相比有不同的体验罢了。客栈老板娘是当地人,与其交谈只觉当地人许是悠闲惯了,事情多有爱理不理的意思。这让我想起平遥在文革那场浩劫中能完整保留下来的原因:穷。因为穷,拆不起城墙建不起高楼,平遥便一直维持着原状,到后来改革开放了,有钱可拆了,在有识之士的劝谏下,又保留了古城,申请了文化遗产,从而成为中国今天保存最为完好的古城。但即使在今天,平遥也还算是个贫穷落后的地方,客栈的老板们似乎只要每年旺季的时候捞上一笔就算了,该过悠闲的日子还是悠闲着。
后来才知道,能住在古城里悠哉悠哉的居民也是有限的。从 1997 年开始,平遥政府为了保护古城,应对日益增多的客流量,开始慢慢迁出古城内的居民,现今古城已外迁近半数人口,只留下 2 万多人。不晓得对迁出的居民来说是幸或不幸,但十几年过去了,平遥的旅游开发似乎都未达到凤凰那般成熟。许是出于保护,许是出于政策,或是本来人们便慵慵懒懒,你来亦好不来也罢,我有我的生活,我住我的古城。
从客栈出来回到北大街,一路商铺食肆虽然繁华,但其实平遥除了冠云牛肉名声在外,其他店铺基本都不入流。这也是平遥旅游开发程度不高的表现之一。北大街一路食肆,吃的名头无非那几样,多属面食,除了名字可能没听过之外,味道都是普通面食的味道,而且淡而无味。价格虽没到其他旅游景区那样高价,但也不算太便宜了。北大街一路下来,除了豆腐脑算挺有味道,就餐的另一家食肆只能说难以下咽,失望而走。
循着地图一路走向南大街,地图上看东西大街是一条直线,南北大街却是错了开来。这与平遥本身的设计格局有关,平遥又名“龟城”,南首北尾,上下东西四门即为神龟四足,城南柳根河河岸蜿蜒,城墙亦随之蜿蜒起伏,柳根河主干汾河在平遥境内是略偏南北走向,于是古城垂直与汾河程略偏东西的南北走向,南城门便立在东南角,再立两口石井意为龟眼。南北大街成“S”形为神龟爬行之态,龟头在东南是朝东摆,龟尾瓮城即拱极门的西北角设计为钝角,则意喻龟尾朝西摆。古人以“龟”筑城,是期望城如龟般固若金汤,长治久安。而平遥历经两千多年仍能保存得这么完好,大约也应了这“龟城”之说了。
上图是现在的我(11 月 28 日)跟 8 月 20 日的对比。原先我是个挺瘦的瘦子,今年 6 月份开始到公司的健身房去尝试增肥锻炼,那时什么都不懂,就是瞎练,举举哑铃做做器械什么的。一个月后算是有点点效果,这时候大病来袭,一场病持续了两个月,我的体重也急剧下降,两个礼拜几乎降了 10 斤。病好了以后决心要把肉练回来,于是一路练到现在。
有同学问我锻炼的方法,想把身体练健康一点。于是我决定把我的健身的经验写下来。首先一点很重要:三分练七分吃。饮食是至关重要的一环。
无论是像我一样吃什么都长不胖的瘦子还是吃什么都容易胖的人,都需要注意饮食。由于肌肉主要是蛋白质组成的,所以每天要保证足够的蛋白质摄入量,简而言之就是少吃多餐。基本上每天的饮食可以这样安排:
大约8点到9点左右。2-4个鸡蛋,吃2个全蛋,可以外加2个蛋白(不要吃太多蛋黄会胆固醇过高,一天两个蛋黄是可以接受的)。我现在只是吃两个全蛋,外加一大杯牛奶,偶尔会加上两片面包。
大约10点半到11点左右。可以吃一个面包或者一杯酸奶,小吃即可。
要保证碳水化合物充足摄入,主要是米饭。如果容易胖的人,中午就不要吃太多肉,吃肉的时候最好吃鸡胸肉,去掉皮,因为皮下脂肪多,容易发胖。
大约下午4点。我一般吃两个蛋糕,保证5点半去健身的时候有足够的血糖,不然健身的时候血液集中到肌肉上容易头晕无力,导致锻炼时间过短。
大约6点半到7点。看自己健身的时间,一般要在健身结束后的两个小时内进食。一般我晚餐吃一块鸡扒,一碗米饭,还有其他的肉和蔬菜。
一般健身训练都安排在下午,所以晚餐至关重要。当你训练的时候肌肉是不会长的,这时候只是刺激肌肉,在休息的两个小时里,机体会寻找蛋白质补充肌肉的劳损,所以这时候必须摄入充足的蛋白质。早餐的牛奶鸡蛋和晚餐的肉就很重要了。
如果是像我一样怎么吃都不胖的人,那就不用理会那些去掉鸡皮啦,少点碳水化合物之类的禁忌,只要不停地吃就可以了。
健身训练为的是两个目的:减脂和增肌。每个人的身体都有肌肉,看不到一是可能肌肉不够发达一是脂肪太多看不出线条,所以要增肌和减脂。但是二者没法同时进行,增肌的时候减不了脂,减脂的时候无法增肌,所以要错开。
增肌靠力量练习,减脂靠有氧运动。一般比较健康的人都容易吃胖,容易吃胖的人建议这样安排训练时间:一周练6天,3天力量练习3天有氧运动,间隔一天力量一天有氧,1天休息。
肌肉分为大肌肉群和小肌肉群,大肌肉群就是胸背腿,小肌肉群就是肱二肱三和肩膀。大肌肉群需要大强度练习,练习后需要休息3天,小肌肉群可以天天练都没关系。一般是一天练一个大肌肉群搭配一个小肌肉群,隔天做一次有氧运动。其中腹肌是特殊的肌肉群,需要每天都练习。因为无论你做什么动作基本都会用到腹肌,所以腹肌是最难疲劳的肌肉,需要天天练才会有效果。
建议饮食容易发胖的人这样安排时间(如果动作不清楚的 google 一下都有视频可以看):
胸(俯卧撑 + 杠铃卧推) + 肱二头肌(哑铃弯举 + 锤击式哑铃弯举 + 二十一响礼炮) + 腹肌(仰卧起坐 + 腹肌八分钟)
有氧运动(跑步机或者疯狂单车30分钟) + 腹肌(仰卧起坐 + 腹肌八分钟)
周三:背(硬拉 + 背阔肌器械 + 杠铃耸肩) + 肱三头肌(哑铃颈后屈伸 + 哑铃臂后弯举) + 腹肌(仰卧起坐 + 腹肌八分钟)
有氧运动(跑步机或者疯狂单车30分钟) + 腹肌八分钟
腿(史密斯机深蹲 + 箭步蹲) + 肩(哑铃前平举 + 哑铃侧平举 + 杠铃划船) + 腹肌(仰卧起坐 + 腹肌八分钟)
以上运动,胸背腿都是每组 15 次,每个动作做 4 组。其他小肌肉群就每组 10 次,一个动作 4 组。腹肌就仰卧起坐 40 次,再做腹肌八分钟。
如果是像我一样吃不胖的,就可以不需要有氧运动来减脂了,直接去掉有氧运动循环一周就行了。当然有氧运动可以增强体力,也是不错的运动。
注意:用到杠铃的运动都是复合运动,一定要在做之前搞清楚动作要点否则锻炼不成反而伤身。比如杠铃卧推,有宽握窄握,一般重量上去了要有人护着否则容易失去平衡砸下来。硬拉和史密斯机深蹲一定要注意动作到位,否则伤膝盖。运动前和做完一组运动之后最好做一下拉伸运动,可以减少肌肉酸痛。
今天在改代码的时候看到定义的 delegate 里面都写了 <NSObject> 在后面:
@protocol APerfectDelegate <NSObject>
@optional
- (void)optionalSel;
@required
- (void)requriedSel;
@end
由于太久没写 ObjC 了,顺手就给去掉了。回头人告诉我这东西编译时会报 warning。我就觉得奇怪了,其实基本上常用的类都是以 NSObject 为基类的,除非是为了周密考虑,把以 NSProxy 为基类的类给排除掉,否则干嘛非得加个 <NSObject> 协议不可。问了人然后自己也试了一下,发现是在这里 warning:
// Instance method 'respondsToSelector:' not found
if ( _delegate != nil && [_delegate respondsToSelector:@selector(optionalSel)] ) {
[_delegate optionalSel];
}
respondsToSelector 这个方法找不到。明白了,遵循 <NSObject> 是为了确保实现了这个方法,这样在调用的时候就可以直接用这个方法检测是否能响应这个 SEL 了。
其实在 ObjC 1.0 的时候,protocol 的这个 @optional 选项是不存在的,所有的 protocol 方法都是必须实现的。所以不遵循 <NSObject> 也没关系,只要判断指针是否存在然后直接调用就完了。但是 ObjC 2.0 加入了 @optional 特性,于是乎必须使用 <NSObject> 的 respondsToSelector: 方法先做一次判断了。
references: Must Delegates Conform To The NSObject Protocol?
注:本文是对 Colin Wheeler 的 Understanding the Objective-C Runtime 的翻译。
初学 Objective-C(以下简称ObjC) 的人很容易忽略一个 ObjC 特性 —— ObjC Runtime。这是因为这门语言很容易上手,几个小时就能学会怎么使用,所以程序员们往往会把时间都花在了解 Cocoa 框架以及调整自己的程序的表现上。然而 Runtime 应该是每一个 ObjC 都应该要了解的东西,至少要理解编译器会把
[target doMethodWith:var1];
编译成:
objc_msgSend(target,@selector(doMethodWith:),var1);
这样的语句。理解 ObjC Runtime 的工作原理,有助于你更深入地去理解 ObjC 这门语言,理解你的 App 是怎样跑起来的。我想所有的 Mac/iPhone 开发者,无论水平如何,都会从中获益的。
ObjC Runtime 的代码是开源的,可以从这个站点下载: opensource.apple.com。
这个是所有开源代码的链接: http://www.opensource.apple.com/source/
这个是ObjC rumtime 的源代码: http://www.opensource.apple.com/source/objc4/
4应该代表的是build版本而不是语言版本,现在是ObjC 2.0
ObjC 是一种面向runtime(运行时)的语言,也就是说,它会尽可能地把代码执行的决策从编译和链接的时候,推迟到运行时。这给程序员写代码带来很大的灵活性,比如说你可以把消息转发给你想要的对象,或者随意交换一个方法的实现之类的。这就要求 runtime 能检测一个对象是否能对一个方法进行响应,然后再把这个方法分发到对应的对象去。我们拿 C 来跟 ObjC 对比一下。在 C 语言里面,一切从 main 函数开始,程序员写代码的时候是自上而下地,一个 C 的结构体或者说类吧,是不能把方法调用转发给其他对象的。举个栗子:
#include < stdio.h >
int main(int argc, const char **argv[]) { printf("Hello World!"); return 0; }
这段代码被编译器解析,优化后,会变成一堆汇编代码:
.text
.align 4,0x90
.globl _main
_main:
Leh_func_begin1:
pushq %rbp
Llabel1:
movq %rsp, %rbp
Llabel2:
subq $16, %rsp
Llabel3:
movq %rsi, %rax
movl %edi, %ecx
movl %ecx, -8(%rbp)
movq %rax, -16(%rbp)
xorb %al, %al
leaq LC(%rip), %rcx
movq %rcx, %rdi
call _printf
movl $0, -4(%rbp)
movl -4(%rbp), %eax
addq $16, %rsp
popq %rbp
ret
Leh_func_end1:
.cstring
LC:
.asciz "Hello World!"
然后,再链接 include 的库,完了生成可执行代码。对比一下 ObjC,当我们初学这门语言的时候教程是这么说滴:用中括号括起来的语句,
[self doSomethingWithVar:var1];
被编译器编译之后会变成:
objc_msgSend(self,@selector(doSomethingWithVar:),var1);
一个 C 方法,传入了三个变量,self指针,要执行的方法 @selector(doSomethingWithVar:) 还有一个参数 var1。但是在这之后就不晓得发生什么了。
ObjC Runtime 其实是一个 Runtime 库,基本上用 C 和汇编写的,这个库使得 C 语言有了面向对象的能力(脑中浮现当你乔帮主参观了施乐帕克的 SmallTalk 之后嘴角一抹浅笑)。这个库做的事前就是加载类的信息,进行方法的分发和转发之类的。
再往下深谈之前咱先介绍几个术语。
目前说来Runtime有两种,一个 Modern Runtime 和一个 Legacy Runtime。Modern Runtime 覆盖了64位的Mac OS X Apps,还有 iOS Apps,Legacy Runtime 是早期用来给32位 Mac OS X Apps 用的,也就是可以不用管就是了。
一种 Instance Method,还有 Class Method。instance method 就是带“-”号的,需要实例化才能用的,如 :
-(void)doFoo;
[aObj doFoot];
Class Method 就是带“+”号的,类似于静态方法可以直接调用:
+(id)alloc;
[ClassName alloc];
这些方法跟 C 函数一样,就是一组代码,完成一个比较小的任务。
-(NSString *)movieTitle
{
return @"Futurama: Into the Wild Green Yonder";
}
一个 Selector 事实上是一个 C 的结构体,表示的是一个方法。定义是:
typedef struct objc_selector *SEL;
使用起来就是:
SEL aSel = @selector(movieTitle);
这样可以直接取一个selector,如果是传递消息(类似于C的方法调用)就是:
[target getMovieTitleForObject:obj];
在 ObjC 里面,用'[]'括起来的表达式就是一个消息。包括了一个 target,就是要接收消息的对象,一个要被调用的方法还有一些你要传递的参数。类似于 C 函数的调用,但是又有所不同。事实上上面这个语句你仅仅是传递了 ObjC 消息,并不代表它就会一定被执行。target 这个对象会检测是谁发起的这个请求,然后决策是要执行这个方法还是其他方法,或者转发给其他的对象。
Class 的定义是这样的:
typedef struct objc_class *Class;
typedef struct objc_object {
Class isa;
} *id;
我们可以看到这里这里有两个结构体,一个类结构体一个对象结构体。所有的 objc_object 对象结构体都有一个 isa 指针,这个 isa 指向它所属的类,在运行时就靠这个指针来检测这个对象是否可以响应一个 selector。完了我们看到最后有一个 id 指针。这个指针其实就只是用来代表一个 ObjC 对象,有点类似于 C++ 的泛型。当你拿到一个 id 指针之后,就可以获取这个对象的类,并且可以检测其是否响应一个 selector。这就是对一个 delegate 常用的调用方式啦。这样说还有点抽象,我们看看 LLVM/Clang 的文档对 Blocks 的定义:
struct Block_literal_1 {
void *isa; // initialized to &_NSConcreteStackBlock or &_NSConcreteGlobalBlock
int flags;
int reserved;
void (*invoke)(void *, ...);
struct Block_descriptor_1 {
unsigned long int reserved; // NULL
unsigned long int size; // sizeof(struct Block_literal_1)
// optional helper functions
void (*copy_helper)(void *dst, void *src);
void (*dispose_helper)(void *src);
} *descriptor;
// imported variables
};
可以看到一个 block 是被设计成一个对象的,拥有一个 isa 指针,所以你可以对一个 block 使用 retain, release, copy 这些方法。
接下来看看啥是IMP。
typedef id (*IMP)(id self,SEL _cmd,...);
一个 IMP 就是一个函数指针,这是由编译器生成的,当你发起一个 ObjC 消息之后,最终它会执行的那个代码,就是由这个函数指针指定的。
OK,回过头来看看一个 ObjC 的类。举一个栗子:
@interface MyClass : NSObject {
//vars
NSInteger counter;
}
//methods
-(void)doFoo;
@end
定义一个类我们可以写成如上代码,而在运行时,一个类就不仅仅是上面看到的这些东西了:
#if !__OBJC2__
Class super_class OBJC2_UNAVAILABLE;
const char *name OBJC2_UNAVAILABLE;
long version OBJC2_UNAVAILABLE;
long info OBJC2_UNAVAILABLE;
long instance_size OBJC2_UNAVAILABLE;
struct objc_ivar_list *ivars OBJC2_UNAVAILABLE;
struct objc_method_list **methodLists OBJC2_UNAVAILABLE;
struct objc_cache *cache OBJC2_UNAVAILABLE;
struct objc_protocol_list *protocols OBJC2_UNAVAILABLE;
#endif
可以看到运行时一个类还关联了它的父类指针,类名,成员变量,方法,cache 还有附属的 protocol。
上面我提到过一个 ObjC 类同时也是一个对象,为了处理类和对象的关系,runtime 库创建了一种叫做 标签类 元类(Meta Class)的东西。当你发出一个消息的时候,比方说
[NSObject alloc];
你事实上是把这个消息发给了一个类对象(Class Object),这个类对象必须是一个 Meta Class 的实例,而这个 Meta Class 同时也是一个根 MetaClass 的实例。当你继承了 NSObject 成为其子类的时候,你的类指针就会指向 NSObject 为其父类。但是 Meta Class 不太一样,所有的 Meta Class 都指向根 Meta Class 为其父类。一个 Meta Class 持有所有能响应的方法。所以当 [NSObject alloc] 这条消息发出的时候,objc_msgSend() 这个方法会去 NSObject 它的 Meta Class 里面去查找是否有响应这个 selector 的方法,然后对 NSObject 这个类对象执行方法调用。
初学 Cocoa 开发的时候,多数教程都要我们继承一个类比方 NSObject,然后我们就开始 Coding 了。比方说:
MyObject *object = [[MyObject alloc] init];
这个语句用来初始化一个实例,类似于 C++ 的 new 关键字。这个语句首先会执行 MyObject 这个类的 +alloc 方法,Apple 的官方文档是这样说的:
The isa instance variable of the new instance is initialized to a data structure that describes the class; memory for all other instance variables is set to 0.
新建的实例中,isa 成员变量会变初始化成一个数据结构体,用来描述所指向的类。其他的成员变量的内存会被置为0.
所以继承 Apple 的类我们不仅是获得了很多很好用的属性,而且也继承了这种内存分配的方法。
刚刚我们看到 runtime 里面有一个指针叫 objc_cache *cache,这是用来缓存方法调用的。现在我们知道一个实例对象被传递一个消息的时候,它会根据 isa 指针去查找能够响应这个消息的对象。但是实际上我们在用的时候,只有一部分方法是常用的,很多方法其实很少用或者根本用不到。比如一个object你可能从来都不用copy方法,那我要是每次调用的时候还去遍历一遍所有的方法那就太笨了。于是 cache 就应运而生了,每次你调用过一个方法,之后,这个方法就会被存到这个 cache 列表里面去,下次调用的时候 runtime 会优先去 cache 里面查找,提高了调用的效率。举一个栗子:
MyObject *obj = [[MyObject alloc] init]; // MyObject 的父类是 NSObject
@implementation MyObject -(id)init { if(self = [super init]){ [self setVarA:@”blah”]; } return self; } @end
这段代码是这样执行的:
OK,这就是一个很简单的初始化过程,在 NSObject 类里面,alloc 和 init 没做什么特别重大的事情,但是,ObjC 特性允许你的 alloc 和 init 返回的值不同,也就是说,你可以在你的 init 函数里面做一些很复杂的初始化操作,但是返回出去一个简单的对象,这就隐藏了类的复杂性。再举个栗子:
#import < Foundation/Foundation.h>
@interface MyObject : NSObject { NSString *aString; }
@property(retain) NSString *aString;
@end
@implementation MyObject
-(id)init { if (self = [super init]) { [self setAString:nil]; } return self; }
@synthesize aString;
@end
int main (int argc, const char * argv[]) { NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init];
id obj1 = [NSMutableArray alloc]; id obj2 = [[NSMutableArray alloc] init];
id obj3 = [NSArray alloc]; id obj4 = [[NSArray alloc] initWithObjects:@"Hello",nil];
NSLog(@"obj1 class is %@",NSStringFromClass([obj1 class])); NSLog(@"obj2 class is %@",NSStringFromClass([obj2 class]));
NSLog(@"obj3 class is %@",NSStringFromClass([obj3 class])); NSLog(@"obj4 class is %@",NSStringFromClass([obj4 class]));
id obj5 = [MyObject alloc]; id obj6 = [[MyObject alloc] init];
NSLog(@"obj5 class is %@",NSStringFromClass([obj5 class])); NSLog(@"obj6 class is %@",NSStringFromClass([obj6 class]));
[pool drain]; return 0; }
如果你是ObjC的初学者,那么你很可能会认为这段代码执的输出会是:
NSMutableArray
NSMutableArray
NSArray
NSArray
MyObject
MyObject
但事实上是这样的:
obj1 class is __NSPlaceholderArray
obj2 class is NSCFArray
obj3 class is __NSPlaceholderArray
obj4 class is NSCFArray
obj5 class is MyObject
obj6 class is MyObject
这是因为 ObjC 是允许运行 +alloc 返回一个特定的类,而 init 方法又返回一个不同的类的。可以看到 NSMutableArray 是对普通数组的封装,内部实现是复杂的,但是对外隐藏了复杂性。
这个方法做的事情不少,举个栗子:
[self printMessageWithString:@"Hello World!"];
这句语句被编译成这样:
objc_msgSend(self,@selector(printMessageWithString:),@"Hello World!");
这个方法先去查找 self 这个对象或者其父类是否响应 @selector(printMessageWithString:),如果从这个类的方法分发表或者 cache 里面找到了,就调用它对应的函数指针。如果找不到,那就会执行一些其他的东西。步骤如下:
在编译的时候,你定义的方法比如:
-(int)doComputeWithNum:(int)aNum
会编译成:
int aClass_doComputeWithNum(aClass *self,SEL _cmd,int aNum)
然后由 runtime 去调用指向你的这个方法的函数指针。那么之前我们说你发起消息其实不是对方法的直接调用,其实 Cocoa 还是提供了可以直接调用的方法的:
// 首先定义一个 C 语言的函数指针 int (computeNum *)(id,SEL,int);
// 使用 methodForSelector 方法获取对应与该 selector 的杉树指针,跟 objc_msgSend 方法拿到的是一样的 // methodForSelector 这个方法是 Cocoa 提供的,不是 ObjC runtime 库提供的 computeNum = (int (*)(id,SEL,int))[target methodForSelector:@selector(doComputeWithNum:)];
// 现在可以直接调用该函数了,跟调用 C 函数是一样的 computeNum(obj,@selector(doComputeWithNum:),aNum);
如果你需要的话,你可以通过这种方式你来确保这个方法一定会被调用。
在 ObjC 这门语言中,发送消息给一个并不响应这个方法的对象,是合法的,应该也是故意这么设计的。换句话说,我可以对任意一个对象传递任意一个消息(看起来有点像对任意一个类调用任意一个方法,当然事实上不是),当然如果最后找不到能调用的方法就会 Crash 掉。
Apple 设计这种机制的原因之一就是——用来模拟多重继承(ObjC 原生是不支持多重继承的)。或者你希望把你的复杂设计隐藏起来。这种转发机制是 Runtime 非常重要的一个特性,大概的步骤如下:
这就给了程序员一次机会,可以告诉 runtime 在找不到改方法的情况下执行什么方法。举个栗子,先定义一个函数:
void fooMethod(id obj, SEL _cmd)
{
NSLog(@"Doing Foo");
}
完了重载 resolveInstanceMethod 方法:
+(BOOL)resolveInstanceMethod:(SEL)aSEL
{
if(aSEL == @selector(doFoo:)){
class_addMethod([self class],aSEL,(IMP)fooMethod,"v@:");
return YES;
}
return [super resolveInstanceMethod];
}
其中 "v@:" 表示返回值和参数,这个符号涉及 Type Encoding,可以参考Apple的文档 ObjC Runtime Guide。
接下来 Runtime 会调用 - (id)forwardingTargetForSelector:(SEL)aSelector 方法。
这就给了程序员第二次机会,如果你没办法在自己的类里面找到替代方法,你就重载这个方法,然后把消息转给其他的Object。
- (id)forwardingTargetForSelector:(SEL)aSelector
{
if(aSelector == @selector(mysteriousMethod:)){
return alternateObject;
}
return [super forwardingTargetForSelector:aSelector];
}
这样你就可以把消息转给别人了。当然这里你不能 return self,不然就死循环了=.=
-(void)forwardInvocation:(NSInvocation *)invocation { SEL invSEL = invocation.selector;
if([altObject respondsToSelector:invSEL]) { [invocation invokeWithTarget:altObject]; } else { [self doesNotRecognizeSelector:invSEL]; }
}
默认情况下 NSObject 对 forwardInvocation 的实现就是简单地执行 -doesNotRecognizeSelector: 这个方法,所以如果你想真正的在最后关头去转发消息你可以重载这个方法(好折腾-.-)。
原文后面介绍了 Non Fragile ivars (Modern Runtime), Objective-C Associated Objects 和 Hybrid vTable Dispatch。鉴于一是底层的可以不用理会,一是早司空见惯的不用详谈,还有一个是很简单的,就是一个建立在方法分发表里面填入默认常用的 method,所以有兴趣的读者可以自行查阅原文,这里就不详谈鸟。
在不使用 ARC 的时候,内存要自己管理,这时重复或过早释放都有可能导致 Crash。
NSObject * aObj = [[NSObject alloc] init]; [aObj release];
NSLog(@"%@", aObj);
aObj 这个对象已经被释放,但是指针没有置空,这时访问这个指针指向的内存就会 Crash。
[aObj release];
aObj = nil;
由于ObjC的特性,调用 nil 指针的任何方法相当于无作用,所以即使有人在使用这个指针时没有判断至少还不会挂掉。
在ObjC里面,一切基于 NSObject 的对象都使用指针来进行调用,所以在无法保证该指针一定有值的情况下,要先判断指针非空再进行调用。
if (aObj) {
//...
}
常见的如判断一个字符串是否为空:
if (aString && aString.length > 0) {//...}
有些时候不能知道自己创建的对象什么时候要进行释放,可以使用 autoRelease,但是不鼓励使用。因为 autoRelease 的对象要等到最近的一个 autoReleasePool 销毁的时候才会销毁,如果自己知道什么时候会用完这个对象,当然立即释放效率要更高。如果一定要用 autoRelease 来创建大量对象或者大数据对象,最好自己显式地创建一个 autoReleasePool,在使用后手动销毁。以前要自己手动初始化 autoReleasePool,现在可以用以下写法:
@autoreleasepool{
for (int i = 0; i < 100; ++i) {
NSObject * aObj = [[[NSObject alloc] init] autorelease];
//....
}
}
NSMutableArray/NSMutableDictionary/NSMutableSet 等类下标越界,或者 insert 了一个 nil 对象。
一个固定数组有一块连续内存,数组指针指向内存首地址,靠下标来计算元素地址,如果下标越界则指针偏移出这块内存,会访问到野数据,ObjC 为了安全就直接让程序 Crash 了。
而 nil 对象在数组类的 init 方法里面是表示数组的结束,所以使用 addObject 方法来插入对象就会使程序挂掉。如果实在要在数组里面加入一个空对象,那就使用 NSNull。
[array addObject:[NSNull null]];
使用数组时注意判断下标是否越界,插入对象前先判断该对象是否为空。
if (aObj) {
[array addObject:aObj];
}
可以使用 Cocoa 的 Category 特性直接扩展 NSMutable 类的 Add/Insert 方法。比如:
@interface NSMutableArray (SafeInsert) -(void) safeAddObject:(id)anObject; @end
@implementation NSMutableArray (SafeInsert) -(void) safeAddObject:(id)anObject { if (anObject) { [self addObject:anObject]; } } @end
这样,以后在工程里面使用 NSMutableArray 就可以直接使用 safeAddObject 方法来规避 Crash。
ObjC 的方法调用跟 C++ 很不一样。 C++ 在编译的时候就已经绑定了类和方法,一个类不可能调用一个不存在的方法,否则就报编译错误。而 ObjC 则是在 runtime 的时候才去查找应该调用哪一个方法。
这两种实现各有优劣,C++ 的绑定使得调用方法的时候速度很快,但是只能通过 virtual 关键字来实现有限的动态绑定。而对 ObjC 来说,事实上他的实现是一种消息传递而不是方法调用。
[aObj aMethod];
这样的语句应该理解为,像 aObj 对象发送一个叫做 aMethod 的消息,aObj 对象接收到这个消息之后,自己去查找是否能调用对应的方法,找不到则上父类找,再找不到就 Crash。由于 ObjC 的这种特性,使得其消息不单可以实现方法调用,还能紧系转发,对一个 obj 传递一个 selector 要求调用某方法,他可以直接不理会,转发给别的 obj 让别的 obj 来响应,非常灵活。
[self methodNotExists];
调用一个不存在的方法,可以编译通过,运行时直接挂掉,报 NSInvalidArgumentException 异常:
-[WSMainViewController methodNotExist]: unrecognized selector sent to instance 0x1dd96160
2013-10-23 15:49:52.167 WSCrashSample[5578:907] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[WSMainViewController methodNotExist]: unrecognized selector sent to instance 0x1dd96160'
像这种类型的错误通常出现在使用 delegate 的时候,因为 delegate 通常是一个 id 泛型,所以 IDE 也不会报警告,所以这种时候要用 respondsToSelector 方法先判断一下,然后再进行调用。
if ([self respondsToSelector:@selector(methodNotExist)]) {
[self methodNotExist];
}
可能由于强制类型转换或者强制写内存等操作,CPU 执行 STMIA 指令时发现写入的内存地址不是自然边界,就会硬件报错挂掉。iPhone 5s 的 CPU 从32位变成64位,有可能会出现一些字节对齐的问题导致 Crash 率升高的。
char *mem = malloc(16); // alloc 16 bytes of data
double *dbl = mem + 2;
double set = 10.0;
*dbl = set;
像上面这段代码,执行到
*dbl = set;
这句的时候,报了 EXC_BAD_ACCESS(code=EXC_ARM_DA_ALIGN) 错误。
要了解字节对齐错误还需要一点点背景知识,知道的童鞋可以略过直接看后面了。
背景知识
计算机最小数据单位是bit(位),也就是0或1。
而内存空间最小单元是byte(字节),一个byte为8个bit。
内存地址空间以byte划分,所以理论上访问内存地址可以从任意byte开始,但是事实上我们不是直接访问硬件地址,而是通过操作系统的虚拟内存地址来访问,虚拟内存地址是以字为单位的。一个32位机器的字长就是32位,所以32位机器一次访问内存大小就是4个byte。再者为了性能考虑,数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
举一个栗子:
struct foo {
char aChar1;
short aShort;
char aChar2;
int i;
};
上面这个结构体,在32位机器上,char 长度为8位,占一个byte,short 占2个byte, int 4个byte。
如果内存地址从 0 开始,那么理论上顺序分配的地址应该是:
aChar1 0x00000000
aShort 0x00000001
aChar2 0x00000003
i 0x00000004
但是事实上编译后,这些变量的地址是这样的:
aChar1 0x00000000
aShort 0x00000002
aChar2 0x00000004
i 0x00000008
这就是 aChar1 和 aChar2 都被做了内存对齐优化,都变成 2 byte 了。
char *mem = malloc(16); // alloc 16 bytes of data
double *dbl = mem + 2;
double set = 10.0;
memcpy(dbl, &set, sizeof(set));
改用 memcpy 之后运行就不会有问题了,这是因为 memcpy 自己的实现就已经做了字节对齐的优化了。我们来看glibc2.5中的memcpy的源码:
void *memcpy (void *dstpp, const void *srcpp, size_t len) {
unsigned long int dstp = (long int) dstpp; unsigned long int srcp = (long int) srcpp; if (len >= OP_T_THRES) { len -= (-dstp) % OPSIZ; BYTE_COPY_FWD (dstp, srcp, (-dstp) % OPSIZ); PAGE_COPY_FWD_MAYBE (dstp, srcp, len, len); WORD_COPY_FWD (dstp, srcp, len, len); } BYTE_COPY_FWD (dstp, srcp, len); return dstpp;
}
分析这个函数,首先比较一下需要拷贝的内存块大小,如果小于 OP_T_THRES (这里定义为 16),则直接字节拷贝就完了,如果大于这个值,视为大内存块拷贝,采用优化算法。
len -= (-dstp) % OPSIZ; BYTE_COPY_FWD (dstp, srcp, (-dstp) % OPSIZ);
// #define OPSIZ (sizeof(op_t)) // enum op_t
OPSIZE 是 op_t 的长度,op_t 是字的类型,所以这里 OPSIZE 是获取当前平台的字长。
dstp 是内存地址,内存地址是按byte来算的,对内存地址 unsigned long 取负数再模 OPSIZE 得到需要对齐的那部分数据的长度,然后用字节拷贝做内存对齐。取负数是因为要以dstp的地址作为起点来进行复制,如果直接取模那就变成0作为起点去做运算了。
对 BYTE_COPY_FWD 这个宏的源码有兴趣的同学可以看看这篇:BYTE_COPY_FWD 源码解析(感谢 @raincai 同学提醒)
这样对齐了之后,再做大数据量部分的拷贝:
PAGE_COPY_FWD_MAYBE (dstp, srcp, len, len);
看这个宏的源码,尽可能多地作页拷贝,剩下的大小会写入len变量。
///////////////////////////////////////////////// #if PAGE_COPY_THRESHOLD
#include <assert.h>
#define PAGE_COPY_FWD_MAYBE(dstp, srcp, nbytes_left, nbytes)
do
{
if ((nbytes) >= PAGE_COPY_THRESHOLD &&
PAGE_OFFSET ((dstp) - (srcp)) == 0)
{
/* The amount to copy is past the threshold for copying
pages virtually with kernel VM operations, and the
source and destination addresses have the same alignment. /
size_t nbytes_before = PAGE_OFFSET (-(dstp));
if (nbytes_before != 0)
{
/ First copy the words before the first page boundary. */
WORD_COPY_FWD (dstp, srcp, nbytes_left, nbytes_before);
assert (nbytes_left == 0);
nbytes -= nbytes_before;
}
PAGE_COPY_FWD (dstp, srcp, nbytes_left, nbytes);
}
} while (0)/* The page size is always a power of two, so we can avoid modulo division. */ #define PAGE_OFFSET(n) ((n) & (PAGE_SIZE - 1))
#else
#define PAGE_COPY_FWD_MAYBE(dstp, srcp, nbytes_left, nbytes) /* nada */
#endif
PAGE_COPY_FWD 的宏定义:
#define PAGE_COPY_FWD ( dstp,
srcp,
nbytes_left,
nbytes
)
Value:
((nbytes_left) = ((nbytes) - \
(__vm_copy (__mach_task_self (), \
(vm_address_t) srcp, trunc_page (nbytes), \
(vm_address_t) dstp) == KERN_SUCCESS \
? trunc_page (nbytes) \
: 0)))
页拷贝剩余部分,再做一下字拷贝:
#define WORD_COPY_FWD ( dst_bp,
src_bp,
nbytes_left,
nbytes
)
Value:
do \
{ \
if (src_bp % OPSIZ == 0) \
_wordcopy_fwd_aligned (dst_bp, src_bp, (nbytes) / OPSIZ); \
else \
_wordcopy_fwd_dest_aligned (dst_bp, src_bp, (nbytes) / OPSIZ); \
src_bp += (nbytes) & -OPSIZ; \
dst_bp += (nbytes) & -OPSIZ; \
(nbytes_left) = (nbytes) % OPSIZ; \
} while (0)
再再最后就是剩下的一点数据量了,直接字节拷贝结束。memcpy 可以用来解决内存对齐问题,同时对于大数据量的内存拷贝,使用 memcpy 效率要高很多,就因为做了页拷贝和字拷贝的优化。
char *mem = malloc(16); // alloc 16 bytes of data
double *dbl = mem + 4;
double set = 10.0;
*dbl = set;
ARM Hacking: EXC_ARM_DA_ALIGN exception
一般情况下应用程序是不需要考虑堆和栈的大小的,总是当作足够大来使用就能满足一般业务开发。但是事实上堆和栈都不是无上限的,过多的递归会导致栈溢出,过多的 alloc 变量会导致堆溢出。
不得不说 Cocoa 的内存管理优化做得挺好的,单纯用 C++ 在 Mac 下编译后执行以下代码,递归 174671 次后挂掉:
#include <iostream> #include <stdlib.h>
void test(int i) { void* ap = malloc(1024); std::cout << ++i << "\n"; test(i); }
int main() { std::cout << "start!" << "\n"; test(0); return 0; }
而在 iOS 上执行以下代码则怎么也不会挂,连 memory warning 都没有:
- (void)stackOverFlow:(int)i {
char * aLeak = malloc(1024); NSLog(@"try %d", ++i); [self stackOverFlow:i];
}
而且如果 malloc 的大小改成比 1024 大的如 10240,其内存占用的增长要远慢于 1024。这大概要归功于 Cocoa 的 Flyweight 设计模式,不过暂时还没能真的理解到其优化原理,猜测可能是虽然内存空间申请了但是一直没用到,针对这种循环 alloc 的场景,做了记录,等到用到内存空间了才真正给出空间。
iOS 内存布局如下图所示:
在应用程序分配的内存空间里面,最低地址位是固定的代码段和数据段,往上是堆,用来存放全局变量,对于 ObjC 来说,就是 alloc 出来的变量,都会放进这里,堆不够用的时候就会往上申请空间。最顶部高地址位是栈,局部的基本类型变量都会放进栈里。 ObjC 的对象都是以指针进行操控的,局部变量的指针都在栈里,全局的变量在堆里,而无论是什么指针,alloc 出来的都在堆里,所以 alloc 出来的变量一定要记得 release。
对于 autorelease 变量来说,每个函数有一个对应的 autorelease pool,函数出栈的时候 pool 被销毁,同时调用这个 pool 里面变量的 dealloc 函数来实现其内部 alloc 出来的变量的释放。
这个应该是全平台都会遇到的问题了。当某个对象会被多个线程修改的时候,有可能一个线程访问这个对象的时候另一个线程已经把它删掉了,导致 Crash。比较常见的是在网络任务队列里面,主线程往队列里面加入任务,网络线程同时进行删除操作导致挂掉。
这个真要写比较完整的并发操作的例子就有点复杂了。
普通的锁,加锁的时候 lock,解锁调用 unlock。
- (void)addPlayer:(Player *)player { if (player == nil) return; NSLock* aLock = [[NSLock alloc] init]; [aLock lock];
[players addObject:player]; [aLock unlock];
} }
可以使用标记符 @synchronized 简化代码:
- (void)addPlayer:(Player *)player {
if (player == nil) return;
@synchronized(players) {
[players addObject:player];
}
}
使用普通的 NSLock 如果在递归的情况下或者重复加锁的情况下,自己跟自己抢资源导致死锁。Cocoa 提供了 NSRecursiveLock 锁可以多次加锁而不会死锁,只要 unlock 次数跟 lock 次数一样就行了。
多数情况下锁是不需要关心什么条件下 unlock 的,要用的时候锁上,用完了就 unlock 就完了。Cocoa 提供这种条件锁,可以在满足某种条件下才解锁。这个锁的 lock 和 unlock, lockWhenCondition 是随意组合的,可以不用对应起来。
这是用在多进程之间共享资源的锁,对 iOS 来说暂时没用处。
无锁
放弃加锁,采用原子操作,编写无锁队列解决多线程同步的问题。酷壳有篇介绍无锁队列的文章可以参考一下:无锁队列的实现
如果一个 Timer 是不停 repeat,那么释放之前就应该先 invalidate。非repeat的timer在fired的时候会自动调用invalidate,但是repeat的不会。这时如果释放了timer,而timer其实还会回调,回调的时候找不到对象就会挂掉。
NSTimer 是通过 RunLoop 来实现定时调用的,当你创建一个 Timer 的时候,RunLoop 会持有这个 Timer 的强引用,如果你创建了一个 repeating timer,在下一次回调前就把这个 timer release了,那么 runloop 回调的时候就会找不到对象而 Crash。
我写了个宏用来释放Timer
/*
* 判断这个Timer不为nil则停止并释放
* 如果不先停止可能会导致crash
*/
#define WVSAFA_DELETE_TIMER(timer) { \
if (timer != nil) { \
[timer invalidate]; \
[timer release]; \
timer = nil; \
} \
}
为了弥补上周只去华侨城和园博园没去拍火车的遗憾,今天就带上N4,DC去拍火车去。中午吃了饭就出发了,外面有点小雨,DC最后没用上,只用了N4就够拍了。
坐上公车一路来到信诺公司站,下了车往西面走,月亮湾大道上全是货柜车,尘土飞扬的。好彩刚下过雨,空气还算清新,用N4拍了张白花,挺好看的。
一路向西,看到铁路公司的大门没敢进,绕了一下到后面,结果是个边防。问了下坐在那里的士兵,他都不晓得附近有火车可以看-.- 完了再去铁路公司问保安,保安说绕到外面走绿化道一直走。我就往外走了,看到有条小路,想起来之前搜到的帖子说要走小路进去,就往里头钻了。结果是条旧的绿化道,以前的人行道,现在没人走了,非常荒凉,很恐怖的感觉。
沿着废人行道往前走,没看出有什么东西来,最后还是捡了条小路往外走了,太恐怖了。结果走着走着到了个驾校。问了小卖部的人,说是前面有个修火车的地方,就我刚走的那条绿化道里面往前走,有个铁丝网就可以看到火车了。于是乎我又往回走,还看到个大叔,在那里吹喇叭。大叔让我直走有条小路可以看到火车,于是我直走,发现又回到刚刚走过来的地方=.=(Stupid
然后,在刚才不走的那里有条小路,钻过重重蜜蜂、蝴蝶、苍蝇、小虫的阻挡,来到一个狗?洞?前面。。。
钻过狗洞,铁轨赫然出现在眼前!
有几个工作人员在那里,后来遇到几个工作人员都说让我离开,闲人免进,不过还是给我拍到了一些好东西,Nexus 4的镜头差强人意,不过也算能看的片子了:D