chim bay

Monday, October 26, 2015

Tiền xử lý và Chỉ định nghĩa macro

nguồn coppy : muteszhacker.wordpress.com.

1. Giới thiệu chung

Chắc hẳn những ai đã học qua C/C++ đều biết đến bộ tiền xử lý (preprocessor), nhưng không phải ai cũng biết rằng nhiều ngôn ngữ biên dịch khác cũng hỗ trợ chức năng này. Sự ra đời của preprocessor bắt nguồn từ sự hạn chế của bộ nhớ máy tính trước đây, không thể chứa hết toàn bộ chương trình nguồn để dịch, do vậy, C/C++ và nhiều ngôn ngữ khác như Visual Basic, Pascal hay thậm chí cả C# đã không dịch ngay chương trình nguồn mà tách riêng thành 2 giai đoạn: giai đoạn Tiền xử lý để xử lý các tệp nguồn, ghi nhận các tùy chọn biên dịch và giai đoạn Dịch. Sau khi thực hiện 2 giai đoạn này, ta mới được mã máy chương trình mong muốn. Bài viết này mạn phép xin được thống kê, trình bày về một số chỉ thị (directive) tiền xử lý trong C++.

2. Chỉ thị #include

Các chỉ thị tiền xử lý trong C/C++ đều bắt đầu bởi ký tự # (hash sign), có thể được đặt ở bất cứ đâu trong mã nguồn chương trình. Sau dấu #, bạn có thể đặt vào một vài dấu trắng (cách, tab), trình biên dịch sẽ bỏ qua chúng. #include là chỉ thị hay gặp nhất. Khi bộ tiền xử lý gặp chỉ thị này trong mã nguồn, nó sẽ thay thế chỉ thị này bằng nội dung trong file mà chỉ thị này đề cập. Có 2 cách sử dụng:
 #include <iostream> 
 #include "stdafx.h"
Điều đầu tiên phải nói: chỉ thị không phải là câu lệnh, cho nên không có dấu ;. Sau này một số ngôn ngữ khác dẫn xuất từ C/C++ như Java, Python kế thừa lại tính năng này, nhưng đã biến nó thành dòng lệnh.
Cách thứ nhất được sử dụng với tập tin header thư viện mà trình biên dịch cung cấp. Nếu trong C, đó có thể là stdio.h hay conio.h mà ta thường gặp. Trong C++, đó có thể là file iostream hay string. Còn cách thứ 2, được sử dụng với đường dẫn đến tập tin header thư viện (.h, .hpp) hoặc file nguồn khác (.c, .cc, .cpp) mà người dùng tự định nghĩa (đường dẫn tương đối hoặc tuyệt đối).
Ngoài ra, nếu ai đã làm quen với Microsoft Assembly 32bit, sẽ thấy có 2 loại câu lệnh:
 include kernel32.inc  ; Include header file
 includelib kernel32.lib  ; Include library file
 ; In high programming language, programmers need only include header files, 
 ; compiler / interpreter will automatically include library files
Trong nhiều ngôn ngữ khác, ta đều có lệnh tương đương với chỉ thị #include:
Java:
 import javax.swing.*;
Python:
 import math
Visual Basic:
 imports System
C#:
 using System;
Thậm chí, trong Pascal cũng cung cấp công cụ tương đương, đó là chỉ thị biên dịch:
 {$INCLUDE Test1.pas}
Hoặc ngắn gọn hơn:
 {$I Test1.pas}
Đến đây, ta nhận ra, hầu như mọi ngôn ngữ bậc cao đều hỗ trợ lệnh / chỉ thị cho phép chèn mã nguồn file khác vào file hiện tại, như đã nói ở đầu bài viết, nó là hệ quả của sự hạn chế của bộ nhớ trước đây. Ngoài ra, việc chia mã nguồn thành nhiều file cũng giúp lập trình viên quản lý code được thuận tiện hơn, tạo điều kiện để xây dựng chương trình lớn.

3. Chỉ thị định nghĩa macro

3.1 Chỉ thị #define

3.1.1 Hằng tượng trưng

Mọi người học C/C++ đều biết chỉ thị define được dùng để định nghĩa hằng tượng trưng (symbolic constant):
 #define MAX_CHILDREN_IN_CHINA 1
Sau lệnh này, mỗi khi trình biên dịch gặp phải xâu MAX_CHILDREN_IN_CHINA trong mã nguồn, nó sẽ thay thế bằng số 1.
Tương tự với #include, các ngôn ngữ khác cũng cung cấp công cụ tương đương nhưng bị giới hạn chức năng hơn. C# kế thừa từ C++, cũng dùng chỉ thị #define nhưng chỉ có thể định nghĩa macro rỗng (sẽ được đề cập bên dưới). Pascal cũng vậy, thông qua chỉ thị biên dịch:
 {$DEFINE UNIX} (*or $D UNIX*)

3.1.2 Macro rỗng

Macro rỗng được dùng để ngăn không cho phép được sử dụng một định danh nào đó trong mã nguồn. Ví dụ:
 #define EMPTY  // Nếu trong mã nguồn có EMPTY, 
                                // nó sẽ không được sử dụng.
Macro rỗng cũng thường được dùng khi ta muốn tạo chương trình chạy trên nhiều nền tảng hệ điều hành. Tham khảo ví dụ sau:
 #define UNIX
 // #define Windows
 #undef Windows

 #ifdef UNIX
  printf("This code will be executed in UNIX");
 #elif Windows
  printf("This code will be executed in Windows");
 #endif
Ý nghĩa của các chỉ thị #undef#ifdef#elif#endif sẽ được đề cập đến ở các phần sau.

3.1.3 Macro có tham số

Như đã nói, khả năng của chỉ thị #define ở các ngôn ngữ khác bị giới hạn hơn C/C++ rất nhiều vì với chỉ thị này, C/C++ có thể cung cấp một công cụ chia nhỏ hàm rất thuận tiện, đặc biệt là khi các hàm này rất đơn giản – macro có tham số. Ví dụ:
Giả sử ta có hàm tính đa thức
 float polynomial(float x) {
  return (x>3.0) ? (x*x*x -1) : (3*x*x + x);
 }
Ta hoàn toàn có thể sử dụng macro sau thay thế:
 #define polynomial(x) ( (x>3.0) ? (x*x*x -1) : (3*x*x + x) )
Gọi hàm:
 printf( "Value is: %f", polynomial(1.2) );
Trong đó, x là tham số, nếu muốn bổ sung thêm tham số khác, ta thêm dấu phẩy để phân cách. Ưu điểm của macro so với hàm bình thường là nó nhận mọi kiểu tham số và không mất thời gian gọi hàm (do đó chương trình chạy nhanh hơn). Tuy nhiên, khuyết điểm đi liền với nó là ta sẽ không thể debug đoạn lệnh trong macro (chính vì vậy hàm inline mới có đất dụng võ). Macro chỉ nên dùng với các hàm đơn giản như tìm min, max, kiểm tra điều kiện…

3.1.4 Truyền một số lượng tham số không định trước vào macro

Bạn đã từng nghe qua varargs (variable arguments – tham biến) trong C/C++? Chắc là chỉ biết nó trong Java thôi. Nếu chưa biết C/C++ sử dụng nó như thế nào thì bạn có thể tham khảo ví dụ sau:
 #include <stdarg.h>

 // Calculate average value of a number list
 double average(int count, ...) {
  va_list ap; //  Required in any file which uses 
                                // the variable argument list (va_) macros

  va_start(ap, count);
                // Requires the last fixed parameter to get the address

  double total = 0;    
  for (int j=0; j<count; ++j)
   total += va_arg(ap, double); 
                        // Requires the type to cast to.
   // Increments ap to the next argument.

  va_end(ap);

  return total/count;
 }
Gọi hàm:
 double avg = average(5, 3.4, 4.0, 0.0, 50.1, 0.34);
Đại để varargs là một công cụ cho phép tạo hàm với số lượng tham số không xác định. va_listva_start,va_end and va_arg là 4 macro được định nghĩa trong thư viện stdarg.h của C và cstdarg của C++.va_list dùng để khai báo danh sách tham biến. va_start định nghĩa biến ap có thêm tham số count là độ dài danh sách. va_arg nạp số tiếp theo từ danh sách tham số. Tham số thứ 2, như trong ví dụ là double, đó là kiểu tham số mà ta mong muốn, cần cẩn thận với tham số này vì nếu ta gọi hàm với tham số sai kiểu mong đợi sẽ dẫn đến lỗi. Cuối cùng, va_end sẽ thu dọn bộ nhớ đã được cấp phát cho danh sách tham biến.
Bạn thấy đấy, cách sử dụng varargs này khá là lằng nhằng mà lại còn tiềm ẩn nguy cơ lỗi lớn. Tuy nhiên ta lại có thể gọi hàm với số lượng và kiểu tham số biến đổi, cũng đáng để mạo hiểm đấy chứ.
Nói dông nói dài về hàm có số lượng tham số biến đổi, bây giờ mới là phần quan trọng nhất của mục này: Truyền một số lượng tham số không định trước vào macro. Một cách rất đơn giản, ta sử dụng macro__VA_ARGS__. Hãy xem ví dụ sau:
 #define LOG(format, ...) printf(format, __VA_ARGS__)
Gọi macro:
 LOG("%c%d%f", 'c', 10, 2.5);
Bạn thấy đấy, sử dụng macro nhiều khi còn tiện hơn hàm rất nhiều.

3.2 Chỉ thị undef

Trong ví dụ ở mục 3.1.2, ta đã thấy chỉ thị này. Đơn giản, nó có tác dụng hủy bỏ định nghĩa một symbol trước đó (do chỉ thị #define tạo ra). Nếu trước đó chưa có định nghĩa nào, thì cũng không ảnh hưởng gì. Tất nhiên, sau khi đã undef thì ta hoàn toàn có thể tái định nghĩa lại bằng #define.

3.3 Toán tử macro

Bộ tiền xử lý cung cấp 2 toán tử có thể được sử dụng để thay thế văn bản (text) này bằng văn bản khác là# và ##.

3.3.1 Toán tử #

Toán tử thứ nhất là stringization tức xâu ký tự hóa. Nếu x là một tham số hinh thức trong macro thì #x sẽ là tham số thực tương ứng. Ví dụ, nếu có:
 #define stringize(x) #x
Thì macro stringize(a+b) sẽ cho kết quả là xâu “a+b”. Kể cả khi tham số của macro la một hằng xâu ký tự, nó cũng trả về nguyên vẹn, tức là stringize(“3”) sẽ trả về “\”3\””.

3.3.2 Toán tử ##

Toán tử ## sẽ kết nối 2 tham số mà bỏ qua khoảng trống giữa chúng. Ví dụ, macro:
 #define glue(a,b) a ## b
Gọi macro:
 glue(c,out) << "test";
Sẽ được trình dịch dịch thành:
 cout << "test";

3.4. Toán tử defined

Toán tử dùng để xác định một từ định danh đã được định nghĩa (bằng chỉ thị #define) hay chưa. Toán tử này chỉ có thể sử dụng kèm với chỉ thị #if và #elif. Nếu định danh đã được định nghĩa thì trả về 1, ngược lại thì trả về 0. Ví dụ:
 #if defined(CREDIT)  // Same to #ifdef CREDIT
  credit();
 #elif defined(DEBIT)  // Same to  #ifdef DEBIT
      debit();

4. Chỉ thị có điều kiện (#ifdef, #ifndef, #if, #endif, #else and #elif)

Các chỉ thị này được dùng để viết nhiều đoạn chương trình khác nhau tương ứng với điều kiện dịch khác nhau. Bằng cách này chúng ta có thể chuyển chương trình từ dạng này sang dạng khác, từ máy này sang máy khác một cách dễ dàng. Có 3 mẫu sau:
 #ifdef ...  // not #ifdef is #ifndef
 ...
 #endif 

 #ifdef ...  // if 
 ...
 #else   // else 
 ...
 #endif 

 #ifdef ...  // if
 ...
 #elif ...  // else if
 ...
 #endif
Tất nhiên là ta hoàn toàn có thể kết hợp, lồng ghép các mẫu trên. Các chỉ thị này được dùng nhiều nhất là trong định nghĩa header thư viện. Ví dụ:
Trong file headerfile.h:
 #ifndef HEADERFILE_H_
 #define HEADERFILE_H_

 class NewClass {
  private:
   // Declare some variables and methods
  public:
   NewClass() {
    // Do something
   }

   void PrintMessage();

   // And more...
 };

 #endif
Trong file headerfile.cpp:
 #include "headerfile.h"

 void NewClass :: PrintMessage() {
  // Print something
 }
Thông qua ví dụ này, ta có thể hiểu cách chia mã nguồn chương trình ra header và mã nguồn chi tiết riêng biệt. Ngoài ra HEADERFILE_H_ là định danh của tên file header headerfile.h mà trình biên dịch biến đổi thành.

5. Chỉ thị #line

Cú pháp:
 #line number "path"
Trong đó number là số hiệu dòng, path là đường dẫn tương đối hoặc tuyệt đối đến file ta muốn. Nếu để trống thì trình biên dịch sẽ tham chiếu đến file nguồn hiện hành (mặc định).
Khi chúng ta dịch một chương trình và gặp lỗi, trình dịch sẽ hiển thị thông báo lỗi với tham chiếu đến dòng lệnh gây lỗi trong file cụ thể. Tuy nhiên ta có thể sử dụng chỉ thị #line để điều khiển file mà ta muốn tham chiếu đến khi gặp lỗi. Giả sử, ta có file errors.h có nội dung:
 int x, y;  // Line 1
 int a;   // Line 2
Và file main.cpp nằm cùng thư mục với errors.h
 #line 8 "errors.h" // Line 1
    // Line 2
 int main(void) { // Line 3
  a=5;  // Line 4
  return 0; // Line 5
 }   // Line 6
Khi biên dịch file main.cpp, đến lệnh gán a = 5, trình dịch phát hiện lỗi và thông báo, ví dụ CodeBlock đưa ra các thông tin sau:
  • File chứa lỗi (File): errors.h
  • Dòng lỗi (Line): 10
  • Thông báo (Message): error: ‘a’ was not declared in this scope
Như vậy, nhờ chỉ thị #line mà khi gặp lỗi, trình biên dịch đã không tham chiếu đến file main.cpp mà lại tham chiếu đến file errors.h. Thứ hai,
 Số của dòng lỗi = số trong chỉ thị #line (ở đây là 8) 
          + số hiệu dòng lỗi thực tế (4)  
          - số hiệu của dòng chứa chỉ thị #line (1) - 1

6. Chỉ thị #error

Chỉ thị này được dùng để nhúng vào trong các cấu trúc chỉ thị có điều kiện nhằm mục đích trình dịch sẽ in ra thông báo lỗi và ngừng biên dịch. Ví dụ:
 #if UNIX
 #error This program can not be run in UNIX system.
 #endif

7. Chỉ thị #pragma

#pragma là một chỉ thị cho compiler biết cách dịch chuơng trình theo một số “tùy chọn” đặc biệt, tùy thuộc vào từng trình biên dịch. Sau đây là một ứng dụng của chỉ thị này: Khai báo struct với kích thước không đổi với mọi trình dịch:
 #pragma pack (push)
 #pragma pack (1)

 struct NewStruct {
  int a;
  int b;
  char c;
 };

 #pragma pack (pop)
Thông thường trình biên dịch sẽ làm tròn để kích thước struct là số chia hết cho 4, (hoặc 2, 8, 16… tùy vào cấu hình build). Với cách khai báo trên trong VC++ và gcc, kích thước cấu trúc sẽ luôn luôn không đổi với mọi trình biên dịch, mọi cấu hình.

0 comments:

Post a Comment