Forward declaration và Header file inclusion trong C++, khi nào nên dùng cái nào ?

Forward declaration

Là việc khai báo trước ( xét trong phạm vi của bài viết ) tên của một class khác ( class B ) trong ( và trước phần khai báo ) của class hiện tại ( class A ).

class B;

class A
{
public:
    void method();

private:
    int data;
};

Header file inclusion

Include thư viện có lẽ là không ai không biết, trong bài này chúng ta chỉ nói đến trường hợp include một header file của class khác ( chẳng hạn class B ), vào header file của class hiện tại ( chẳng hạn class A ).

#include "B.h"

class A
{
public:
    void method();

private:
    int data;
};

Đặt vấn đề

Có thể thấy, việc include hay khai báo trước class B trong class A đều phục vụ chung một mục đích đó là để có thể sử dụng class B trong class A. Hai cách làm này chắc chắn không tương đương nhau, vậy chúng khác nhau như thế nào và trong trường hợp nào thì nên dùng Header file inclusion/ Forward declaration.

Khi nào dùng Forward declaration ?

Khi sử dụng Forward declaration class B trong class A, chúng ta làm cho class A biết đến sự tồn tại của class B, tức là chỉ quan tâm đến phần khai báo của class B, mà không cần biết định nghĩa của nó. Điều này làm hạn chế những thứ mà chúng ta có thể làm với class B ở trong class A, và dẫn đến những trường hợp chúng ta nên dùng Forward declaration sau đây:

  •  Trong phần định nghĩa của A chỉ có dữ liệu kiểu con trỏ B thay vì một dữ liệu kiểu B ( B * b, thay vì B b ). Bởi vì khi ta khai báo một kiễu dữ liệu của B ( B b ), compiler cần phải biết toàn bộ class B, nghĩa là cả phần khai báo và phần định nghĩa; điều này là không cần thiết khi chỉ khai báo con trỏ đến B.
  •  Không có bất cứ sự sử dụng định nghĩa của B ( như gọi hàm của B, truy cập dữ liệu của B… ) trong phần định nghĩa của A.
  •  Nếu class B rất lớn và phức tạp, bạn có thể tránh include nó càng ít càng tốt, thay bằng Forward declaration, nhằm giảm thời gian compile
  •  Nếu B thay đổi thường xuyên: trong trường hợp này nếu dùng forward declaration, thì B phải được recompiled, A.cpp nếu có sử dụng phần định nghĩa của B ( như gọi hàm của B… ) thì cũng phải được recompiled, tuy nhiên A.h và bất cứ file nào include A.h đều không phải recompiled. Vì như đã đề cập ở trên, A.h chỉ quan tâm đến sự tồn tại của B, nên những thay đổi ở B cũng không làm thay đổi sự tồn tại đó, do đó A.h không cần recompiled, điều này giúp giảm thời gian biên dịch. ( * )

( * ): So sánh với cách Header file inclusion trong trường hợp này thì nếu B thay đổi, thì cả A, và bất cứ file nào include A.h ( hay B.h ) cũng phải được recompiled, thậm chí nếu chúng không hề sử dụng phần định nghĩa của B. Điều này là dễ hiểu khi mà toàn bộ nội dung của file include ( B.h ) được sao chép và thế chỗ dòng include này ( sau quá trình tiền xử lí – preprocessor )

class B;
class A
{
private:
    B * b_1;    // ok, it's a pointer of type B
    B & b_2;    // ok, it's a reference to type B
    B b;        // WRONG !

public:
    void method()
    {
        b_1->data;   // WRONG, can't access B's data
        *b_1.data;   // WRONG, can't de-reference
        b_1->doSomething();    // WRONG, can't use B's function
    }
};
  • Khi hai class cần include lẫn nhau: ta xem xét trường hợp sau đây:
// A.h
#include "B.h"    // WRONG here
class B;          // This will work
class A
{
    B * b;
};

// B.h
#include "A.h"
class B
{
    A a;
};

Khi biên dịch chương trình sẽ báo lỗi, vì sự include qua về lẫn nhau dẫn đến việc class này sẽ không bao giờ biết được sự tồn tại của class kia ( ngay cả khi đã có include guard hay #pragma once ). Ta giải quyết vấn đề này bằng cách sử dụng Forward declaration cho class A vì class A chỉ chứa con trỏ đến B

Khi nào dùng Header file inclusion ?

Từ những trường hợp của Forward declaration, chúng ta có thể dễ dàng suy ra những trường hợp dùng Header file inclusion:

  • Sử dụng định nghĩa của B ( truy cập dữ liệu, sử dụng hàm của B .. )
  • Sử dụng sizeof ( B ) ( nếu chỉ biết sự tồn tại của B thì không thể dùng được size of đối với trường hợp Forward declaration )
  • Sử dụng RTTI
  • Sử dụng new/delete, copy… cho B
  • Nếu class A kế thừa từ B
  • Có dữ liệu kiểu B ( B b )

Happy coding 🙂

Advertisements

Nested function và vấn đề khai báo hàm bên trong một hàm khác

Nested function

Nested function là hàm được định nghĩa bên trong một hàm khác.
Nested function được hỗ trợ như là một mở rộng của GNU C, và không được hỗ trợ bởi GNU C++. Mặc dù đã có đề xuất đưa tính năng này lên C++ nhưng nó đã bị từ chối vì nhiều lí do khác nhau ( không có sự hữu dụng nào cho generic programming…. ).
C++ 11 ra đời với tính năng Lambda Expression, như một sự thay thế cho Nested function.
Tuy nhiên chúng ta vẫn có thể khai báo ( declare ) một hàm bên trong một hàm khác ở C++, như là một yếu tố cần cho sự tương thích khi phát triển từ C lên C++

Khai báo hàm bên trong hàm khác ở C++

Việc này giúp tránh được vấn đề làm “pollute global namespace” – có thể hiểu là tránh sự trùng lặp ( về tên ) của một hàm được định nghĩa bên ngoài với một hàm đang có ở global namespace ( hoặc là từ thư viện được include vào chương trình ). Bản chất là dùng tính chất phạm vi của các hàm, biến khi được khai báo, biến/ hàm nào được khai báo gần nhất thì sẽ được ưu tiên sử dụng.

Cá nhân mình không gặp nhiều cách viết khai báo hàm bên trong hàm khác như thế này, cũng có thể là một bad pratice, nhưng vì tò mò muốn biết nên mới tìm hiểu. Thực sự thì mình tin là cũng sẽ không implement cái này nhiều trong code của mình.

Xem xét đoạn code sau:

#include <iostream>
namespace ns
{
void func() {};
}
using namespace std;
int main()
{
void func();
func();
}void func()
{
std::cout << "External function\n";
}

Kết quả sẽ là dòng External function. Việc khai báo hàm func() trong hàm main cho compiler biết rằng kể từ sau việc khai báo, hàm func được sử dụng sẽ là hàm func ở bên ngoài namespace ns.

Cần chú ý ở đây rằng nếu bạn khai báo hàm func ở ngoài ( ngay bên trên) hàm main, trình biên dịch sẽ báo lỗi vì nó không biết ta sẽ sử dụng hàm func nào ( ambiguous ). Việc khai báo ở trong hay ngoài hàm giống nhau ở chỗ compiler sẽ đều đi tìm phần định nghĩa ( definition ) ở tất cả các file khác trong cùng project ( *.h, *.cpp ), từ khoá “extern” là thừa vì nó là mặc định khi declare hàm

Khi bạn include các thư viện ( stl, các thư viện bên thứ 3… ), để sử dụng hàm của riêng bạn, tránh xung đột với các hàm trong các thư viện đó, các bạn cũng có thể sử dụng kĩ thuật này.

Happy codding 🙂

Lỗi khi định nghĩa cho class template trong file .cpp và cách khắc phục

Lỗi gặp khi làm bài tập xây dựng stack, mình có file header như sau:

// stack.h 

template <typename T>
class NStack
{
private:
    T * elem;
    int sz;
public:
    NStack();

    // declaring other methods
};

Và file .cpp:

// stack.cpp
#include "stack.h"
#define MAX_STACK_SIZE 100
template <typename T>
NStack<T>::NStack() 
: elem { new T[ MAX_STACK_SIZE ] },
  sz { 0 }
{}

// defining other methods

Ở chương trình chính, khi bạn khai báo NStack<int> s chẳng hạn, và build thì chương trình sẽ báo lỗi:

Undefined symbols for architecture x86_64:”NStack<int>::NStack()”, referenced from:
_main in exe3.o
ld: symbol(s) not found for architecture x86_64

Đây là một lỗi gặp khi link, compiler không tìm được phần define của constructor NStack<int>::NStack()

Nguyên nhân là do: Một template class, không phải là một class, mà nó chính xác là một “khuôn mẫu” dùng để tạo ra class. Khi ta khai báo NStack<int> s; ta phải chỉ rõ tất cả các template member functions để compiler có thể tạo ra các member functions thuộc kiểu int. Nhưng vì phần define function( ở đây chỉ nêu mẫu constructor ) lại ở file .cpp ( ở file .h ta chỉ declare nó ), compiler không tìm thấy khi “link” các nguyên mẫu hàm ở file .h, vậy nên gây ra lỗi LNK như trên.

Có 3 cách khắc phục cho vấn đề này:
Cách 1: Định nghĩa tất cả các hàm ở trong file header:

// stack.h 

#define MAX_STACK_SIZE 100
template <typename T>;
class NStack
{
private:
    T * elem;
    int sz;
public:
    NStack()
    {
        elem = new T [MAX_STACK_SIZE];
        sz = 0;
    }
    // declaring & defining other methods
}

Làm như cách một sẽ gộp phần khai báo và định nghĩa lại làm một, không hay cho lắm, ta đi đến cách 2 và cách 3 như sau.

Cách 2: Thông báo trước cho compiler biết những kiểu dữ liệu trừu tượng nào ta muốn dùng ở cuối file cpp ( trường hợp ở đây là int )

// stack.cpp
#include "stack.h"
#define MAX_STACK_SIZE 100
template<typename T>
NStack<T>::NStack()
: elem { new T[ MAX_STACK_SIZE ] },
  sz { 0 }
{}

// defining other methods

//...

// Dùng 1 trong 2
template class NStack<int>;
NStack<int> __temp;

Phương án này cũng chưa tốt ghi mà chúng ta phải khai báo trước những kiểu dữ liệu cụ thể trước khi sử dụng, mà điều này nó lại không hợp logic lắm với ý nghĩa của template. Ta đến với cách 3 như sau.

Cách 3: Bỏ toàn bộ phần code định nghĩa hàm vào một file tạm là .tpp ( để compiler bỏ qua file này, không biên dịch, tránh xung đột khi include qua về với file header ). Sau đó include file .tpp này vào cuối file header

// stack.h 

template <typename T>
class NStack
{
private:
    T * elem;
    int sz;
public:
    NStack();

    // declaring other methods
}

#include "stack.tpp"

Happy coding 🙂