Nội dung bài viết
La Kiến Vinh Bài viết hướng tối ưu hóa trong lập trình với C++, tối ưu hóa lập trình C++ với games, bài viết hướng games bởi vì games đòi hỏi hiệu năng rất cao, và các games lớn thông thường sử dụng C++ làm nền tảng.

Giới thiệu

Games đòi hỏi hiệu năng rất cao so với các vấn đề về lập trình phần mềm khác. Do đó, các nhà phát triển thường chọn C/C++ để phát triển games của họ, khác với các loại lập trình khác, với games nếu ta có thể hiểu biết và sâu sắc hơn, ta sẽ tạo ra nhiều khác biệt trong codes và hiệu năng sẽ cao hơn.

Lập trình nói chung, lập trình C/C++ hay lập trình games nói riêng có rất nhiều vấn đề để bàn vì nhóm này thường đòi hỏi hiệu suất cao. Với nhiều năm phát triển games của bản thân, chủ yếu với vài ngôn ngữ lập trình như C/C++, Java hay C# và hàng tá ngôn ngữ lập trình khác mà tôi đã từng làm qua thì cốt lõi vẫn là cách tư duy trong codes.

Tiền đề bài viết

Bài viết này tôi dành tặng cho các bạn, cho các học trò của mình với hơn 5 năm phát triển games tại công ty games lớn mà tôi tham gia phát triển và với việc phát triển các sản phẩm cá nhân, kinh nghiệm rất quý giá nên không thể để nó mai một, do đó tôi cố gắng cô đọng lại trong bài viết của mình.

Đối tượng hướng đến

Các bạn có hiểu biết tốt về kỹ thuật lập trình và khả năng C/C++ mức chuyên nghiệp; mặc dù nó đề cập games, nhưng thật tế nó thiên về khoa học máy tính chung nhất và có thể áp dụng cho nhiều ngôn ngữ lập trình khác.

Danh sách bài

  1. Tư Duy Tối Ưu Hóa Trong Lập Trình Games - Phần 1: Codes Trong C/C++
  2. Tư Duy Tối Ưu Hóa Trong Lập Trình Games - Phần 2: Quản Lý Bộ Nhớ Phân Mảnh

Các trường hợp tối ưu hóa codes

  • Tạo một đối tượng khi thật sự cần thiết
  • Gọi hàm khởi tạo khi thật sự cần thiết
  • Chỉ định hàm khởi tạo cho các đối tượng là thuộc tính của class
  • Sử dụng tham chiếu nếu có thể (với C++)

Tạo một đối tượng khi thật sự cần thiết

Khi thật sự không cần thiết, ta nên hạn chế khởi tạo 1 đối tượng vì chi phí bao gồm hiệu suất cho khởi tạo và thu hồi rất lớn, bên cạnh đó còn tốn bộ nhớ để lưu trữ.

Ta xét trường hợp sau

void BeAStdio()
{
	Stdio sObj;
	
	...
}

Từ đoạn ... trở xuống, ta không hề sử dụng sObj nên đó là 1 sự lãng phí ta phải bỏ. Tuy nhìn đơn giản nhưng rất nhiều bạn vẫn không xóa nó đi. Và chính vì thế cho đến lúc code phức tạp hơn 1 chút như bên dưới

char* BeAStdio(bool enable)
{
	Stdio sObj;
	
	if (enable == true)
		return NULL;
	else
		...
}

Vấn đề ở chỗ, nếu enable == true thì hàm sẽ ngưng thực thi và trả NULL ra ngoài, do đó sObj sẽ không để làm gì cả. Chính vì sự bất cẩn nhỏ thường xuyên, sẽ dẫn đến các bất cẩn đáng tiếc khi mọi thứ phức tạp hơn. Vậy vấn đề chốt lại là, hãy tạo thói quen tốt, đó là tính tiết kiệm.

Thật sự vấn đề này cũng được đề cập rất nhiều trong STDIO, bạn có thể vào lại trang chủ tại STDIO và tìm hiểu thêm các bài viết liên quan như Sơ Lược Về Phong Cách Lập Trình hay STDIO Coding Convention - Level 1.

Gọi hàm khởi tạo khi thật sự cần thiết

Mặc định ta khởi tạo 1 đối tượng, hàm tạo (constructor) sẽ được gọi sau khi hàm đó được khởi tạo. Giả sử trong hàm khởi tạo có dạng sau

#include <string.h>

typedef char byte;

class StdioMember
{
private:
	byte* m_profilePicture;
	int m_profilePictureLength;
	
	char* m_DNAString;
	int m_DNAStringLength;

public:
	StdioMember(int pictureLength, byte* profilePicture,
				int DNALength, char* DNA)
	{
		m_profilePicture = new byte[pictureLength];
		m_profilePictureLength = new char[DNALength];
		
		memcpy(m_profilePicture, profilePicture, pictureLength);
		memcpy(m_DNAString, DNA, DNALength);
	}
};

int main()
{
	/*......*/

	StdioMember member(picLength, pic, dnaLength, dna);

	/*......*/
	
	return 0;
}

Ngay từ lúc khởi tạo StdioMember member thì constructor sẽ được gọi ngay sau đó và điều thuận tiện này cũng là điều bất lợi, vì đôi lúc đó chưa phải là thời điểm mà ta muốn khởi tạo các giá trị cho thuộc tính trong đối tượng, có khi thời điểm đó ta cũng đang cần hiệu suất của máy tính và muốn việc khởi tạo được diễn ra sau đó (khi nào mà ta muốn), vậy ta nên "thương lượng" lại với constructor.

Nếu chưa cần khởi tạo, ta có 2 cách và thông thường ta sử dụng pointer và khi nào cần khởi tạo ta sẽ cấp phát động bằng toán tử new. Nhưng giả sử bài toán đặt ra là phải cấp phát đối tượng nhưng chưa cần khởi tạo các giá trị trong đối tượng thì ta sẽ thiết kế lại lớp như sau.

  • Thêm 1 constructor với thân của constructor là rỗng.
  • Viết thêm 1 phương thức khởi tạo và ta sẽ gọi phương thức đó khi nào ta thấy cần thiết.

Vậy ta được code như sau

#include <string.h>

typedef char byte;

class StdioMember
{
private:
	byte* m_profilePicture;
	int m_profilePictureLength;
	
	char* m_DNAString;
	int m_DNAStringLength;

public:

	StdioMember()
	{
	}
	
	void Init(int pictureLength, byte* profilePicture,
				int DNALength, char* DNA)
	{
		m_profilePicture = new byte[pictureLength];
		m_profilePictureLength = new char[DNALength];
		
		memcpy(m_profilePicture, profilePicture, pictureLength);
		memcpy(m_DNAString, DNA, DNALength);
	}
};

int main()
{
	/*......*/

	StdioMember member;

	/*......*/
	
	member.Init();
	
	return 0;
}

Chỉ định hàm khởi tạo cho các đối tượng là thuộc tính của class

Với 1 thuộc tính trong 1 class, có thể đó là 1 đối tượng, nếu như khai báo thông thường ta xem như đã gọi constructor mặc định cho nó, và không có cách thức để gọi các constructor mà ta mong muốn.

class Sins
{
private:
	string m_name;
	
public:
	Sins(string & name)
	{		
		m_name = name;
	}
}

Phân tích codes trên ta có, 2 quá trình: Sins được tạo ra, Sins gọi constructor.

  • Trong quá trình đối tượng Sins được tạo thành thì m_name được tạo. Phân tích riêng m_name được tạo, nó sẽ gọi constructor của nó.
  • Vào thân hàm constructor của Sins và xảy ra copy-constructor gắn name vào m_name.

Xét riêng m_name ta thấy nó phải gọi constructor trước, sau đó lại tiến hành gán (gọi copy-construtor) thêm 1 lần nữa. Vậy ta nghĩ đến vấn đề, tại thời điểm khởi tạo m_name ta sẽ gọi thẳng copy-constructor để tránh thêm bước gọi constructor.

Để làm điều này, ta sẽ chỉ định copy-constructor cho Sins khi nó đang khởi tạo bằng cách sau

class Sins
{
private:
	string m_name;
	
public:
	Sins(string & name):m_name(name)
	{
	}
}

Tôi có giải thích thêm về vấn đề này trong bài viết Sự Ra Đời Của Phương Thức Khởi Tạo (Constructor), Phương Thức Hủy (Destructor) Trong Một Class - C++.

Sử dụng tham chiếu nếu có thể (với C++)

Với C, ta chỉ có pointer, bản chất khi truyền pointer vào 1 hàm hay gán giá trị pointer cho 1 pointer khác vẫn là sao chép giá trị, nhưng nếu được ta có thể dùng phương pháp truyền giá trị của pointer thay vì truyền cả object dẫn đến deep-copy.

Với C++, các thiết kế viên đã tạo nên khái niệm đặt lại tên cho 1 biến, tức là nó thậm chí không tốn kém thêm 1 byte nào để truyền. Biến mới với nguyên tắc chỉ là 1 cái tên khác cho biến hiện tại và 1 vùng nhớ xem như có nhiều tên gọi.

Do "đặt lại tên" nghĩa là đối tượng đó sẽ không khởi tạo lại, mà cùng dùng chung 1 vùng nhớ, nếu ta chỉ có mục tiêu là lấy dữ liệu của đối tượng đó (chỉ đọc, không ghi) thì đây là 1 cách thức rất tốt.

Như hàm vẽ 1 đối tượng tên là Sins bên dưới, ta không cần thiết phải sao chép cả đối tượng, vì trong trường hợp này ta chỉ muốn lấy dữ liệu từ nó để vẽ (bạn có thể sử dụng phương pháp truyền địa chỉ qua pointer nếu muốn, nhưng ở đây tôi dùng phương pháp tên chung - truyền tham chiếu).

void DrawSins(Sins & s, int posX, int posY)
{
	Draw(s.texture, s.rect.x, s.rect.y, 
					s.rect.width, s.height,
					posX, posY);
}
THẢO LUẬN
ĐÓNG