Tránh lạm dụng Singleton

Mở đầu

Singleton là một design pattern rất phổ biếnbiến. Đối với iOS developer nói riêng, chúng ta rất quen thuộc khi làm việc với singleton qua một số loại đã được Apple định nghĩa sẵn ví dụ như: UIApplication, NSFileManager, ... Xcode còn cung cấp sẵn code snipe Dispatch Once để việc implement singleton dễ dàng hơn:

+ (instancetype)sharedInstance
{
    static dispatch_once_t once;
    static id sharedInstance;
    dispatch_once(&once, ^{
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}

Chính vì sự tiện lợi này, các singleton được sử dụng rất phổ biến trong lập trình iOS. Điều này dẫn đến 1 hệ luỵ singleton rất dễ bị lạm dụng quá mức.

Một số developer còn gọi singleton là một "anti-pattern". Chúng ta không thể phủ nhận sự tiện lợi hoặc cần thiết của singleton. Nhưng chúng ta sẽ đi tìm hiểu một số vấn đề của singleton để tránh các vấn đề đau đầu khác do quá lạm dụng singleton gây ra.

Global state

Hầu hết các developer đồng ý rằng global mutable state là một việc tồi tệ. Biến toàn cục làm cho chương trình rất khó hiểu và khó debug. Các lập trình viên hướng đối tượng cần phải học hỏi nhiều từ các lập trình viên chuyên viết functional programing (lập trình hàm) trong việc giảm thiểu biến toàn cục trong code.

@implementation SPMath {
    NSUInteger _a;
    NSUInteger _b;
}

- (NSUInteger)computeSum
{
    return _a + _b;
}

Đoạn code trên implement một phép toán đơn giản, developer mong muốn rằng sẽ gán biến _a và _b với các giá trị tương ứng trước khi gọi hàm computeSum. Có một số vấn đề đặt ra:

  • computeSum bị phụ thuộc hoàn toàn vào trạng thái của hai hiến _a và _b thay vì lấy giá trị từ parameters truyền vào. Thay vì chỉ nhìn vào interface để hiểu được những biến sẽ quyết định output của hàm thì developer khác phải đọc implementation của hàm để hiểu được sự phụ thuộc của output vào 2 biến global _a và _b.

  • Khi thay đổi giá trị của _a và _b, các developer cần phải chắc chắn sự thay đổi không gây ảnh hưởng đến các code khác cũng dựa trên 2 biến _a và _b. Điều này đặc biệt khó khăn trong các chương trình sử dụng multithreding nhiều.

Ngược lại với ví dụ trên:

+ (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b
{
    return a + b;
}

Trong ví dụ trên, sự phụ thuộc vào 2 biến a, b được làm rõ ràng. Chúng ta ko cần thay đổi trạng thái các instance để gọi method trên. Chúng ta cũng không cần quan tâm đến side effect khi gọi method trên, chúng ta cũng có thể khai báo method trên là một class method để chỉ ra rằng nó không ảnh hưởng gì đến trạng thái của các instance.

Ví dụ trên liên quan đến việc sử dụng singleton như một nơi lưu các biến toàn cục. Singleton có thể sử dụng ở mọi nơi mà không cần khai báo phụ thuộc.Bất cứ module đều có thể gọi [SPMySingleton sharedInstance] và truy cập được vào singleton. Điều này đồng nghĩa với việc khi bạn thay đổi các property của singleton có thể làm ảnh hưởng đến các thành phần cũng dùng đến singleton này.

@interface SPSingleton : NSObject

+ (instancetype)sharedInstance;

- (NSUInteger)badMutableState;
- (void)setBadMutableState:(NSUInteger)badMutableState;

@end

@implementation SPConsumerA

- (void)someMethod
{
    if ([[SPSingleton sharedInstance] badMutableState]) {
        // ...
    }
}

@end

@implementation SPConsumerB

- (void)someOtherMethod
{
    [[SPSingleton sharedInstance] setBadMutableState:0];
}

@end

Trong ví dụ trên, SPConsumerA và SPConsumerB là 2 phần riêng biệt. Ta có thể thấy SPConsumerB có thể thay đổi giá trị của property badMutableState, SPConsumerA lại sử dụng badMutableState cho một logic nào đó. Logic trên nên được thay bằng một reference của class SPConsumerB đến class SPConsumerA. Sử dụng singleton ở đây gây ra sự kết nối tiềm ẩn giữa 2 module riêng biệt.

Điều này cũng rất nguy hiểm nếu chúng ta sử dụng unit test. Giả sử chúng ta chạy các test case riêng biệt nhưng code chúng ta lại sử dụng singleton. Khi unit test được chạy có một số biến global được thay đổi từ các test case trước có thể làm cho các test case chạy sau fail.

Object Lifecycle

Vấn đề tiếp theo của singleton chúng ta đề cập là vòng đời của chúng. "Singleton là class chỉ có 1 instance" định nghĩa của singleton khá đơn giản nhưng trong thực thế, việc chỉ một object tồn tại trong suốt vòng đời của app lại nảy sinh ra nhiều vấn đề cần giải quyết. Cho ví du, chúng ta có một app social mà người dùng có một friend list. Mỗi friend có profile picture, các thông tin liên quan được lấy từ APIs. Chúng ta muốn download và cache các image này. Chúng ta build một singleton như sau:

@interface SPThumbnailCache : NSObject

+ (instancetype)sharedThumbnailCache;

- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;

@end

Chúng ta tiếp tục phát triển app, cho đến một ngày, khách hàng thêm chức năng logout, user có thể chuyển đổi giữa các account trên device. Khi user logout khỏi app, chúng ta muốn xoá đi tất cả dữ liệu liên quan đến user trước cũng như các cache. Trường hợp này sẽ dẫn đến các cache cũ sẽ vẫn tồn tại trên device và tốn kha khá bộ nhớ. Bên cạnh đó, chúng ta cũng cần một object SPThumbnailCache cho new user. Điều này lại đi ngược với định nghĩa của singleton.Chúng ta có thể nghĩ ra 1 số cách work around ở đây như:

static SPThumbnailCache *sharedThumbnailCache;

+ (instancetype)sharedThumbnailCache
{
    if (!sharedThumbnailCache) {
        sharedThumbnailCache = [[self alloc] init];
    }
    return sharedThumbnailCache;
}

+ (void)tearDown
{
    // The SPThumbnailCache will clean up persistent states when deallocated
    sharedThumbnailCache = nil;
}

Cách trên là một sự lạm dụng quá mức singleton nhưng nó vẫn hoạt động. Đến lúc này, chúng ta sẽ cân nhắc có nên implement image cache là một singleton. Nhưng ngay từ khi bắt đầu dự án, chúng ta đã thiết kế nó là một singleton. Vậy nếu chúng ta thay đổi cacheThumbnailCache thì sẽ ảnh hưởng rất lớn đến toàn bộ dự án, nhiều bug con có thể sinh ra.

Bài học ở đây là singleton chỉ nên được dùng cho các trạng thái global và không gắn với bất kỳ scope nào. Nếu scope của một trạng thái là giới hạn thì nó không nên được quản lý bởi singleton.

Avoiding Singletons

Làm sao chúng ta có thể tránh sử dụng singleton cho các trạng thái bị giới hạn vùng ảnh hưởng.

Cùng quay lại ví dụ trên. Các thumbnailCache sẽ phụ thuộc vào người dùng, chúng ta định nghĩa thêm object user:

@interface SPUser : NSObject

@property (nonatomic, readonly) SPThumbnailCache *thumbnailCache;

@end

@implementation SPUser

- (instancetype)init
{
    if ((self = [super init])) {
        _thumbnailCache = [[SPThumbnailCache alloc] init];

        // Initialize other user-specific state...
    }
    return self;
}

@end

Chúng ta có thể lưu các trạng thái của người dùng trong object này. Tiếp theo ta có friend list như sau:

@interface SPFriendListViewController : UIViewController

- (instancetype)initWithUser:(SPUser *)user;

@end

Chúng ta có thể truyền object user vào view controller. Cách implement trên giúp chúng ta có thể kiểm soát được SPFriendListViewController với user tương ứng, kiểm soát được các session dễ dàng.

Giả sử SPFriendListViewController là rootViewController. Với singleton model chúng ta có sơ đồ như dưới đây.

8d8033ad7a08120e9ed7c846dbb2a77e.png

view controller và các imageview tương tác với sharedThumbnailCache. Khi user log out, chúng ta muốn thoát và đưa người dùng trở lại trang log in.

Screen Shot 2014-06-02 at 5.53.45 AM-fef7b626.png

Một vấn đề đặt ra là friend list view controller có thể vẫn còn thực thi code ở background có thể dẫn đến các lời gọi hàm không mong muốn đến sharedThumbnailCache.

Ngược lại, ta có giải pháp cải tiến với dependency injection:

Screen Shot 2014-06-02 at 5.38.59 AM-a341630e.png

Giả sử để đơn giản hoá, chúng ta cho SPApplicationDelegate quản lý object SPUser. Khi friend list viewcontroller được gọi lên, object SPUser sẽ được truyền đến. Khi người dùng log out, chúng ta sẽ có sơ đồ như sau:

Screen Shot 2014-06-02 at 5.54.07 AM-8275c2ed.png

Sơ đồ nhìn gần như tương tự khi chúng ta sử dụng singleton. Vấn đề ở đây là phạm vi sử dụng. Trong trường hợp sử dụng singleton, sharedThumbnailCache vẫn có thể truy cập từ các thành phần khác của chương trình. Trường hợp người dùng sign in vào một account mới, người dùng sẽ view lại friend list, nghĩa là sẽ sử dụng sharedThumbnailCache.

Screen Shot 2014-06-02 at 5.59.25 AM-87634b36.png

Khi người dùng sign in vào một account mới, chúng ta nên khởi tạo và làm việc với SPThumbnailCache mới. friend list view controller và SPThumbnailCache cũ sẽ được dọn dẹp ở background dựa vào ARC.Chúng ta nên cô lập trạng thái của 2 user mới và cũ.

Screen Shot 2014-06-02 at 6.43.56 AM-524a953d.png

Conclusion

Rất nhiều lập trình viên đã than phiền về việc lạm dụng singleton trong nhiều năm và chúng ta đều biết global state là một implement rất tệ. Nhưng trong lập trình iOS, singleton rất phổ biến đến nỗi chúng ta có thể quên đi các khái niệm hướng đối tượng.

Lập trình hướng đối tượng sinh ra để chung ta có thể giới hạn scope của các trạng thái, object. Singleton đi ngược lại với tiêu chí đó, chúng ta có thể làm cho một trạng thái được truy cập từ mọi nơi trong chương trình. Mỗi khi quyết định sử dụng singleton, hy vọng bạn có thể xem xét sử dụng dependency injection như một cách thay thế.

Bài viết được dịch từ : https://www.objc.io/issues/13-architecture/singletons/