一道Block面试题的深入挖掘


0. 序言

最近看到了一道Block的面试题,还蛮有意思的,来给咱们分享一下。

本文从一道Block面试题动身,层层深化到达Block原理的解说,把面试题吃得透透的。

题外话:

很多人觉得Blo6 J – 0 # Yck的定义很奇怪,很难记住。但其实和C言语的函数指针的定义对比一下,你很w ! _ G K简略就能够记住。

// Block
returnType (^blockName)(parameterTypes)
// 函数指针
returnType (*c_func)(parameterTypes)

例如输入和回来参数都@ m 6 d f P 3是字符串:

(char *) (*c_func)(const cr E b R H ahar *);
(NSString *) (^block)(NSString *);

好了,下面正式开端~

1. 面试题

1.1 问题1

以下代码存在内存走漏么?

  • 不存在
  • 存在
- (void)viewDidLoad {
[super viewDidLoad];
Nq ! l $ |SNotif} # e m B x 1 !icatiU d V D b }onCenter *__weak center = [NSNotificationCe S q (  ^ 2 %enter defaultCenter];
id token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NS! C H l { X K {OperationQueue mainQueue]
usingBlock:^(NSNotification * _Nonnull note) {
[self doSoS m g l _ ( Imething];
[center r_ 3 D . memoveObserves R E P Gr:token];
}];
}
- (void)doSomething {
}

答案是存在

1.1.1 剖析
  • block中,咱们运用到的外部: / P U变量有selfcenteQ ` i F 2 t G 2rcenter运用了__weak说明符必定没问题。

  • center持有tokentoken持有blockblo~ F k ; 7 h 0 1 ock持有sel) 0 S ) R jf,也便是说token不开释,self必定无法开释。

  • 咱们留意到[center removeObserver:token];这步会把tokencenter中移除去。按理说,centerself是不是就f C = Q N1 Q a | 0 z d :够被开释了呢?

咱们来看看编译器怎样说:

一道Block面试题的深入挖掘

编译器告知咱们,tok} _ Men在被block捕获之前没有初始化[center removeObserver:token];是无法正确移除too - k 5 ] F 7 7ken1 8 %,所以self也无法被开释!

一道Block面试题的深入挖掘

为什么没有被初始化?

由于token在后面的办法执行完才会被回来。办法执行的时分token还没有被回来,所以捕获到的是一个未初始化的值!

1.2 问题2

以下代码存在内存走漏么?

  • 不存在
  • 存在
- (void)vid F @ .ewDidLoad {
[super viewDidLoad];
NSNotificationCenter *__weak centert I j 9 ! o = [NSNotificationCenter d. X V C { [ 2 ) befaul| n +tCenter];
id __block token = [center addObserver) j  a # L h %ForNamw N K V Ie:UIApp8 I d ] n ] F {lication+ G 5 T T ,DidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:Q ] ) 5 [ ,^(NSNotification * _Nonnull note) {
[self doSomething];
[center removeObserver:token];
}];
}
- (void)doSomething {
}

这次代码在token之前参加了__bloG A wck说明符。

提示:这次编译器没有正告说token没有被初始化了。

答案是仍是存在

1.2.1 剖析

首先,证明token的值是正确的,一起咱们也能够看到token的确是持有block的。

一道Block面试题的深入挖掘

那么,为什么还会走漏/ N $ z | O ] E呢?

由于Q e n j,尽管centertoken的持有已经没有了,token现在还被block持有。

或许还有同学会问:

参加了__| & ( w X !block说明符,token目标不是仍是center回来之后才干拿到么,为什么加了之后就没问题了呢?

原因会在Block原理部分详细说Y z s p & u & | Y明。

1.3 问题3

以下代码存在内存走漏么?

  • 不存在
  • 存在
- (void)viewDidLoad {
[super vH u _ 8 I + D 6iewDidLoad];
NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
id __block token = [center addObserverForName:UIApplicationDidEnterBackgroundO 9 I ]Notification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification * _Nonnull note) {
[5 , f V  t e _sel` [ L * z | vf doSomething];
[center removeObserver:token];
token = nil;
}];
}
- (void)doSomething {
}
- (void)dealloc {
NSLog(@"%s", __FUC ! g rNCTI9 s & LON__);
}

答案是不存在T Y ( 1

1.3.1 剖析

咱们能够验证一下:

一道Block面试题的深入挖掘

能够看到,咱们增加token = nil;之后,ViewController被正确开释了。这一步,解除了tokenbloM i ] Ock之间的循环引证,所以正确开释了。

有人或许会说:

运用__weak typeof(self) wkSelf = self;就能够处理self不M M h #开释的问题。

的确这能够处理self不开释的问题,可是这儿仍然存在内存走漏!

2. Block的原理

尽管面试题处理了,可是还有几个问题没有弄清楚:

  • 为什么没有__block说明符token未被初始化,而R ] l G r有这个说明符之后就没问题了呢?
  • tokenblock为什么会形成循环引证呢?

2.1 Block捕获自动变量

刚刚的面试题比较复杂,咱们j 4 _ 9 H c先来看一个简略的:

Block转化为C函数之后A o X,Block中运用的自动变量会被作为成员变量追加到 __X_blocR % K w a k lk_impl_Y结构体中,其间 X一般是函数名, Y是第几个Block,比方main函数中的第0个结构体: __maix # $ N en_block_impl_0

typedef void (^MyBlock)(void);
int main(in` W Jt argc, const char * argv{ = a 0 0[])
{
@autoreleasepool
{W 3 M $
int a6 d # Dge = 10;
MyBlock block = ^{
NS7 Y I 2Log(@"age = %d", age);
};
age = 18;
block();
}
return 0;
}

趁便说^ z q = T * J + s一下,这个输出:age = 10

在命令行中对这个文件进行一下处理:

clang -w -reV P 5 U d xwrite-objc maiD E W ~ L B w @n.m

或者

xcrun -sdk iphoneos clang -arch arm64 -w -rewril d E (te-obj . ` d V o -jc main.m

区别是下面指定了SDK和架构代码会少一点。

处理完之后会生成一个main.cpp的文件,翻开后会发现代码很多,不要怕。搜索int main就能看到了解的代码了。

int main(int arA { 6 ~ } 4gC l D 1 U X K qc, coI + Z H D )nst char * a- ( n | irgv[])
{
/* @autoreleasepool */
{ __AtAutoreleasePool __autoreleasepool;
int age = 10;
MyBlock block = ((void (*)())&__main$ J * W $ ^ ( e_block_impl_0((void *)__main_block_func_0, &__main k C ;_block_desc_0_DATA, age));
age = 18;
((v! y 9 . g G _ F -oid (*)(__b_ v _ m ? Y u =lock_impl *))((__bL / | ` B 1 ]lock_impl *)block)->FuncPtr)((__block_impl *)block);
}
return 0;
}

下面是main函数中涉及到的一些结构体:

struct __main_block_id  b | 1mpl_0 {
struct __block_impl@ I J / impl; //block的函数的imp结构体
struct __main_block_f w x Y B E Q T 1desb ( O (c_0* Desc; // block的信息
int age; // 值引证的age值
__main_block_impl_0(void *fp, struct __main_block_desc_0 *des_ 4 V w 7 v d oc, int _age, int flags=0) : age(_age) {
impl.isa = &_NSConcreteStackBlock; // 栈类型的block
impl.FlagsZ 5 H / X = flags;
impl.FF z funcPtr = fp; // 传入了函数详细的imp指针
Descy - * , + p Z = desc;
}
};
struct7 1 M ! N & Q __block_impl {
void *isa; //$ Z  block的类型:大局8 - M . Q Q b、栈、堆
int Flags;
int ReservedK { n;
void *FuncPtr; // 函数的指针!便是通过它调用block的!
};
static strus l L A Gct __main_block_desc_0 { // block的信息
sM ( : [ | 6 * A +izeQ F t _ : ; (_t reservu 8 C ?ed;
size_t Block_size; // block的巨细
} __main_bloc% 9 J Gk_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

有了这些信息,咱们再看看

MyBlock_ 3 5 G { b 6 block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age));

能够看到,block初始化的时分age是值传递,所以block结构体中agR i Ce=10,所以打印的是age = 10

2.2 __bl0 R J s } % _ock说明符

Block中修正捕获的自动变量有两种办法:

  • 运用静态变量、静态大局变量、大局变量

    从Block语法转化为C言语函数中拜访静态大局变量、大局变量,没有任何不同,能够直接拜访。而静态变量运用的是静态变量的指[ W i q 2 I i针来进行拜访。

    自动变M m m y p i量不能选用静态变量的做法进行拜访。原因是,自动变量是在存储在栈上的,当超出O ] F @ w Y t其效果域时,会被@ 1 + { r 5 ] P栈开释。而静态变量是存储在堆上的,超出效果域时,静态变量没有被开释,所以还能够拜4 x 4 [访。

  • 增加 __block润饰符

    __block存储域类说明符。存储域说明符会指定变量存储的域,如栈aut% & + K do、堆static、大局extern,寄存器register。

比方刚刚的代码加上 __block说明符:

typedef void (^MyBlock)(void);
int m$ J eain(int argc, const char * argv[])
{
@autoreleasepool
{
int __block age = 10;
MyBlock block = ^{
age = 18;
};
block();
}
return 0;
}

在命令行中对这个文r z [ w 2件进行一下处理:

xcrun -sdk iphone+ ; +  i 2 )os clang -arch arm64 -w -rewrite-objc main.c # k r 2 1 ! ( _m

咱们看到main函数发生了变化:

  • 本来的age变量:int age = 10;

  • 现在的age变量:__Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age, 0, sV b n R E a l 2 Pizeof(__Block_byref_age_0), 10};

int main(int argc, const char *}  | argv[])
{
/* @autoreC K n P N # ` 9 Xleasepool */
{ __AtAutoreleasePool __autoreleasepool;
__Block_byref_age_0 age = {(voS H Q Nid*)0,(__Block_byref_age_0 *)&age, 0, sizeof(__Block_byref_age_0), 10};
MyBlock block = ((void (*)())&__main_block_i] 8 T j E ] #mpl_0((void *)__main_bC ~ X c x _ W mlock_func_B * f c Z ~ x P 80, &__main_block_desc_0_DATA, (__Block_byre= J Z / l @ y Rf_age_0 *)&age, 570425344));
((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
}
return 0;
}

本来咱们知道增加 __block说明符,咱们就能够在block里边修正自动变量了。

祝贺你,现在你达到了第二层!__block说明符,其实会把自动变量包含到一个结构体中。

这也就解说了问题i n C 8 . l1为什么参加__block说明符,token能够正确拿到s y ? 值。

MyBlock block = ((void (*)())&_+ [ X i 0 O_main_block_imV l ` #pl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_age_0 *)&age, 570425344));

这次block初始化的过程中,把age这个结构体传入到了block结构p C Z i 5 d B `体中,现在就变成了指针引证

struct __Block_byref_( u # P 8 tage_0 {
void *__isa;c / O h //isa指针
__Block_byref_age_0 *__forwardl C b A Z ? 1 { !ing; // 指向自己的指针
int __flags; // 符号
iX 6 l W ^ + ~nt __size; // 结构体巨细
int age; // 成员变量,存储age值
};
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0*W 0 $ Z ` e o G | Desc;
__Blocky  x t )_byref_age_0 *age; // 结构体指针引证
__main_block_impl_0(void *fp. = 7 +, struct __main_block_desc_0 *desc, __Block_byref_age_m H A 0 *_age, int fa f h  : 9 .lags=0) : age(_j d [ # i sage->__forwarding) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flag+ ] ls;
impl.FuncPtr6 u [ B J ! J ? x = fp;
Desc = desc;
}
};

咱们再来看看block中是怎样修正age对应的值:

static void __i 0 _ V }main_block_funw N v c Sc_0(struct __main_block_impl_0 *__cself) {
__Block_byref_a1 n 3 $ Sge_0 *age = __cself->age; // 通过结构体? ( a ]的self指针拿到age结构体的指针
(age->__forwarding->age) = 18; // 通过age结构体指针修正age值
}

看到这儿或许不明白__forwardinz ` : + ?g的效果,咱们之后再讲。现在知道是age是指针引证修正成功的就能够了O q & a B P

2.3 Block存储/ ( M ^ –

从C代码中咱们Z Y W U ~ ( V能够看到Block的是指是Block结构体实例__block变量实质是栈上__ba D p :lock变量结构体实例。从初始化函数中咱们能够看~ x 8 8 ~到,impl.isa = &_NSConcreteStackW * J f { e ?Block;,即之前咱们运用的是栈Block。

其实,Block有3中类型:

  • _NSConcreteGlobalBlock类目标存储在程序的数据~ P r 6区(.data区)。
  • _NSConcreteStackBlock类目标存储在栈上。
  • _NSF 8 K W U Z d a TConcreteMallocBlock类目标存储在堆上。
void (^blk)(void) = ^{
NSLog(@"G ; R T ` g y ?Global Block");
};
int maiP y ) 0n() {
blk();
NSLog(@"%@",[blk clas_ b Ws]);//打印:__NSGlobalBlock__
}

大局Block必定是存储在大局数据区的,可是在函数栈上创立的Block,假如没有捕获自动变量,Block的结构实例仍是 _NSConcreteGlobalBlock,而不是 _NSConcreteStacV 7 } K o { bkBlock

void (^blk0)(void) = ^{ // 没有截获自动变量的B* ; ] K S ^lock
NSLog(@"Sta? K {ck Block");
};
blk0();
NSLog(@"%@",[blk0 class]); // 打印:__NSGloE  8balBlock__
in9 | O v [t i = 1;
void (^blk1)(voidK O -  $ ( * = 1) = ^{ // 截获自动变量i的Blockh 0 M L & H O x
NSLog(@j T $ o S : a"Capture:%d", i);
};
bs q w m Ylk1();
NSLog(@"%@",[blk1 class]); // 打印:__NSMallocBlock__

能够看到没有捕获自动变量的Block打印的0 H v – u v类是NSGlobalBlock,表明存储在大局数据区。 但为什么捕获自动变量的Block打印的类却是设置在堆上的NSMallocBlock,而非栈上] y i ^ g A Q ~NSStackBlock?这个问题稍后解说。

设置在栈上的B J $ e 6 J O N uBlock,假如超出效果域,Block就会被开释。若 __block, 4 t变量也装备在栈上,也会有被开释的问题。所以, copy办法调用时,__block变量也被仿制到堆上,一起impl.isa = &_NSConcreteMallocBlock;。仿制之后,栈上 __block变量的__forwarding指针会指向堆上的目标。因 此 __bloc2 X o D 7 8 [ qk变量无论被分配在栈上仍是堆上都能够正确! 0 ) ! q ` q O &拜访。

编译器怎样判别何时需求进行copy操作呢?

在ARC敞开时,自动判别进I T + r K l & R @copy

  • 手动调用copy
  • 将Bl7 o H D ` ] e V –ock作为函数参数回来值回来时,编译器会自动进行 copy
  • 将Block赋G W b | , P值给 copy润饰的^ } u $ ] . r #id类或者Block类型成员变量,或者__strong润饰的自动变量。* F M 6 q i
  • 办法名含有usin4 b 7 # v J kgBlockCocoa结构办法或GCD相关API传递Block。

假如不能自动 copy,则需求咱们手动调用 copy办法~ X r = t ( i 9将其仿制到堆上。比方向不包括上面B ) o c A说到的办法或函数的参数中传递Block时。

ARC环境下,回来一个目标时会先将该目标仿制给一个临时实例指针,然后进行retain操作,# y m / l i W b ?再回来目标指针。runtime/oC e } { 6 a + 3bjc-arr.mm # ? Y D到,Block的retain操作objc_retainBlock函数实践上是Block_copy函数。在实行retain操作objc_reD Z ] - % / #tk v t # | 5 Q o VainBlock后,栈上的Block会! 7 o t m p #被仿制到堆上,一起回来堆上的地址作为指针赋值给临时变量。

2.4 __block变量存储域

一道Block面试题的深入挖掘

当Block从栈仿制到堆上时分,__block变量也被仿制到堆上并被Block持有。

  • 若此时 __block变量已经在堆上,则被该Bloc+ Q # i bk持有。
  • 若装备在堆上的Block被开释,则它所持有的 __block变量也会被开释。
__block inJ . Dt val = 0;
vq [ y 6 ?oid (^block)(void) = [1 G e /^{ ++val; } coS : d , r Upy];
++val;
block();

运用 copy操作,Block和 __block变量都从栈上被仿制到了堆上。无论L D { ++val; }仍是++val;都转化成了++(val->__foZ = F C &rwarding->val);

Block中的变量val为仿制到堆上的 __block变量结构体实例,* c ] ^ 0而Block外的变量val则为仿制前栈上的 __block变量结构体实例,但这个结构体的__forwarding成员变量指向堆上的 __block变量结构体实例。所以,无论是是在Block内部仍是外部运用 __block变量,都能够顺利拜访同一个 __block变量。

3. 面试题C代码

下面咱们看看面试题u * I M Z的C代d } e Z V q I码。

@interf{ c m g [ ( 1ace Test : NSObject
@end
@impl& / ! Lementation Test
- (vok d P R @ v i - :id)test_notification {
NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
id __block tW ? Token = [center addObserverForName:@"L 1 _ 9 F 2 com.demo.perform.once"
object:nil
queue:[NS9 x 0Operatio[ u } ; h [ 7 h InQueue mainQueue]
usingBlock:^(NSNotification * _Nonnull no} ! G 9 M 1 ~te) {
[self doSomething];
[center removeObserver:token];
token = nil;
}];
}
- (void)doSomething {
}
@end

3.1 重写

在命令行中对这个文件进行一下处理,由于用到了 __wer M ; e F Qak说明符,需求额外指定一些参数:

xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc -fobjc-arc -mios-version-min=8.0.0 -fobjc-runt d f Vime=ios-8.0.0 main.m

这个会更复杂一些,但咱们只看重要的部分:

struct __BlocM h J j J p N _k_byref_token_0 {
void *__isa;
__BA c |lock_byref_token_0 *__forwarding;# R J o 6 O z
int __flags;
int __size;
void (*__Block_byr( ! h + ( ?ef_id_obf . g g 8 p P S Nject_copy)(void*, void*);
void (*__Block_byrG 9 s , y s vef_id_p M A X r / aobject_dispose)(v; ? m qoid*);
__strong id token;~ v v  : R // id类型的token变量 (strong)
};
struct __Test__test_notification_block_impl_0 {O w q -
struct __block_implv m P ~ r impl;
struct __Test__test_notification_block_desc_0*k j 2 DescM % @ ] 3 =;
Test *cr d z $ | V r P #onst __strong self; // 被捕获0 ( G y  $的self (g u Tstrong)
NSNotificationCenter *__weak center/ p o 5 B  :; // center目标 (weak)
__Block_byref_token_0 *token; // token结构体的指针
__Test__test_notification_block_impl_0(void * A [ e Z W afp, strc H P - r a o 0 %uct __Test__test_notification_block_desc_0 *desc, Test *const __strong _self, NSz D 9 & } O j 2 $NotificationCenter *__weak _center, __Block_byref_token_0 *_token,V ] T V & ? int flags=0) : self(_self), center(_center), token(_token->__forwarding) {O #  ] 3
impl.isa = &_NSConcreteStackBlock;
impl.Flags = fh v 9 ( ] : q 1 ;lags;
impl.FuncPtr = fp;
Desc = desc;
}
};

现在咱们C A + ` 3 Q o看到block结构体 __Test__test_notification_bloce 1 C . 6 k ck_impl_0中持有token,一起之前咱们看到token也是持) c K # u T kblock的,所以造成了循环引证4 s , %

这也K f j V { k就答复了问题2。

下面咱们看看blockIMP函数是怎样处理循环引证问题的:

static vh + Y Y C V Hoid __Test__test_notification_block_func_0(struct __Test__test_notification_block_O W V o B q O Pimpl_0 *__cself, NSNotifi$ ` 1 0 [cation * _Nonnull __strong note) {
__Block_byref_token_0 *token = __cself->T o * + = V j m U;token; // bound by ref
Test *const __strong self = __cself->self; // bound by copy
NSNotificationCenter *__weak center = __cself->ceO ^ & f # a g {nter; // bound by copy
((void (*)(id, SEL))(void *)objc_msgSend)((id)self, sel_reJ o @ H r *gisterName("doSomething"));
((void (*)(id, SEL, id  _Nonnull __strong))(void *)objc_msgSend)((id)center, sel_registerName("removeObserver:"), (id)(tog E k $ 6 r 6ken->__forwarding->token));
(th f 5oken->__forwarding->token) = __nuS k 9 I Jll;
}

能够看到,token = nil: I N 3 U;被转] l g 5 * [ D ] e化为了(token->__forwarding->token) = __nuP $ M Y K v . 0 Nll;,相当于block目标对token的持有解除了!假如你觉得看不太明白,我再转化一下:

(__cself->token->__forwarding->token) = __null; // __cself为block结构体指针

3.2 Block的类型

仔细的同学或许发现:

impl.isa = &_NSConcreteS: 4 Z 6  T EtacF $ g j e / d AkBlo3 W j Ack;

这是一个栈类型block呀,声明周期结束不是就该被系统收回开释了么。咱们运用了ARC一起咱们调用是办法名中含有usingBlockG k } x,会自动触发 copy操作,将其仿制f n U 9 ; 1到堆上。

一道Block面试题的深入挖掘

4. 总结D E m /

BlE H Vock最常问的便是循环引证、内存走漏问题* ~ `

留意关键:

  • __weak说明符的运用
  • _y r 7_R g @ V F R 4 5block: c [ [ V { % I 说明符的运用
  • 谁持有谁
  • 怎样解除循环引证

别的,需求再着重一下的是:

  • 面试题中的block代码假j b : { g ! 2如一次都没有执行也是会内存走漏的!

  • 或许有人会说运用__weak typeof(self) wkSelf = self;就能够处理self不开释的问题。

    的确这能够处理self不开释的问题,可是这h ; H仍然存在内存走漏! 咱们仍是需求从根上处理这个问题。

弥补:

上面讲的时分9 7 K会集在说tokenb% p 9 9lock的循环引证,ViewCon@ Y J 7troller的问题我简略带过了,或许同学们看的时分没有留意到。

我在这儿专门拎出来说一下:

tokenblock循环引证,一起block持有self(ViewController),导致ViewController也无法开释。

假如期望优先开释ViewController(不论block是否执行),最好给ViewController加上__weak说明符。

此外,破除tokenblock的循环引证,实践有两种办法:

  • 手动设置token = nil;
  • token也运用__weak说明符id __blocH 7 ] - o b y Ak __weak token

留意:

以下说法不行谨慎,也或许存在问题:

最简略粗暴的处理办法:咱们都__weak

NSM h } G h J Q } ?NotificationCenter *__weak^ l [ wkCenter = [NSNotificationCentk L u X i ger >defaultCenter];
__weak typeof(self) wkSelG * g p %f = self;
id __block __weak wkToken = [h ] ~ S @ d W C MwkCenter addObserverForName:U/ [ zIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQue/  |ue mainQueue]
usingBlock:^(NSNotification * _Nonnull nm W ?ote) {
[wkSelf doSomething];
[wkCe4 6 & i g z Jnter removeD 7 v { D 2Observer:wkToken_ v D g { Y %];
}];

这个问题详细G O H B o / B s (要看NSNG l YotificationCenter详细是怎样完成的。toke( i J p ;n运用__weak说明符,可是假如NSNotificationCenter没有持有token,在函数8 B l P = S =效果域结束时d D | [ G / ) b ]token会被销毁。尽管不会有循环引证问题,可是或许导致无法移除这个观察者的问题。

假如觉得本文对你有所帮助,给我点个赞吧~

一道Block面试题的深入挖掘一道Block面试题的深入挖掘

发表评论

提供最优质的资源集合

立即查看 了解详情